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

This commit is contained in:
Matilde Park 2021-04-23 14:57:17 -04:00
commit 0a71fb89e2
80 changed files with 3478 additions and 1983 deletions

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d56b7351a347a65c06999955114f196523a86c853390d5d1822a90a606619d6
size 10357558
oid sha256:f6b5e33e573818120051651c1182163527edbbe0dff0eb6591e12a55cfccb273
size 10486101

View File

@ -1,4 +1,4 @@
{ urbit, libcap, coreutils, bashInteractive, dockerTools, writeScriptBin, amesPort ? 34343 }:
{ urbit, curl, libcap, coreutils, bashInteractive, dockerTools, writeScriptBin, amesPort ? 34343 }:
let
startUrbit = writeScriptBin "start-urbit" ''
#!${bashInteractive}/bin/bash
@ -59,11 +59,41 @@ let
exec urbit $ttyflag -p $amesPort $dirname
'';
getUrbitCode = writeScriptBin "get-urbit-code" ''
#!${bashInteractive}/bin/bash
raw=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{ "source": { "dojo": "+code" }, "sink": { "stdout": null } }' \
http://127.0.0.1:12321)
# trim \n" from the end
trim="''${raw%\\n\"}"
# trim " from the start
code="''${trim#\"}"
echo "$code"
'';
resetUrbitCode = writeScriptBin "reset-urbit-code" ''
#!${bashInteractive}/bin/bash
curl=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{ "source": { "dojo": "+hood/code %reset" }, "sink": { "app": "hood" } }' \
http://127.0.0.1:12321)
if [[ $? -eq 0 ]]
then
echo "OK"
else
echo "Curl error: $?"
fi
'';
in dockerTools.buildImage {
name = "urbit";
tag = "v${urbit.version}";
contents = [ bashInteractive urbit startUrbit coreutils ];
contents = [ bashInteractive urbit curl startUrbit getUrbitCode resetUrbitCode coreutils ];
runAsRoot = ''
#!${bashInteractive}
mkdir -p /urbit

View File

@ -126,6 +126,14 @@
!=(contact(last-updated *@da) u.old(last-updated *@da))
==
[~ state]
~| "cannot add a data url to cover!"
?> ?| ?=(~ cover.contact)
!=('data:' (cut 3 [0 5] u.cover.contact))
==
~| "cannot add a data url to avatar!"
?> ?| ?=(~ avatar.contact)
!=('data:' (cut 3 [0 5] u.avatar.contact))
==
:- (send-diff [%add ship contact] =(ship our.bowl))
state(rolodex (~(put by rolodex) ship contact))
::
@ -149,6 +157,14 @@
=/ contact (edit-contact old edit-field)
?: =(old contact)
[~ state]
~| "cannot add a data url to cover!"
?> ?| ?=(~ cover.contact)
!=('data:' (cut 3 [0 5] u.cover.contact))
==
~| "cannot add a data url to avatar!"
?> ?| ?=(~ avatar.contact)
!=('data:' (cut 3 [0 5] u.avatar.contact))
==
=. last-updated.contact timestamp
:- (send-diff [%edit ship edit-field timestamp] =(ship our.bowl))
state(rolodex (~(put by rolodex) ship contact))

View File

@ -5,7 +5,7 @@
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0v6.qafur.17301.j8obh.vbepn.7tq3l
++ hash 0v3.g6u13.haedt.jt4hd.61ek5.6t30q
+$ 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.fd3d400454968e081ca9.js"></script>
<script src="/~landscape/js/bundle/index.59e682153138f604d358.js"></script>
</body>
</html>

View File

@ -23,7 +23,7 @@
:: /app-name/%app-name associations for app
:: /group/%path associations for group
::
/- store=metadata-store
/- store=metadata-store, pull-hook
/+ default-agent, verb, dbug, resource, *migrate
|%
+$ card card:agent:gall
@ -95,16 +95,17 @@
~
==
::
+$ state-0 [%0 base-state-0]
+$ state-1 [%1 base-state-0]
+$ state-2 [%2 base-state-0]
+$ state-3 [%3 base-state-1]
+$ state-4 [%4 base-state-1]
+$ state-5 [%5 base-state-1]
+$ state-6 [%6 base-state-1]
+$ state-7 [%7 base-state-2]
+$ state-8 [%8 base-state-3]
+$ state-9 [%9 base-state-3]
+$ state-0 [%0 base-state-0]
+$ state-1 [%1 base-state-0]
+$ state-2 [%2 base-state-0]
+$ state-3 [%3 base-state-1]
+$ state-4 [%4 base-state-1]
+$ state-5 [%5 base-state-1]
+$ state-6 [%6 base-state-1]
+$ state-7 [%7 base-state-2]
+$ state-8 [%8 base-state-3]
+$ state-9 [%9 base-state-3]
+$ state-10 [%10 base-state-3]
+$ versioned-state
$% state-0
state-1
@ -116,10 +117,11 @@
state-7
state-8
state-9
state-10
==
::
+$ inflated-state
$: state-9
$: state-10
cached-indices
==
--
@ -232,7 +234,7 @@
=| cards=(list card)
|^
=* loop $
?: ?=(%9 -.old)
?: ?=(%10 -.old)
:- cards
%_ state
associations associations.old
@ -240,7 +242,7 @@
group-indices (rebuild-group-indices associations.old)
app-indices (rebuild-app-indices associations.old)
==
?: ?=(%8 -.old)
?: ?=(%9 -.old)
=/ groups
(fall (~(get by (rebuild-app-indices associations.old)) %groups) ~)
=/ pokes=(list card)
@ -252,13 +254,17 @@
?. ?=([%group [~ [~ [@ [@ @]]]]] config.met)
~
=* res resource.u.u.feed.config.met
?: =(our.bowl entity.res) ~
=- `[%pass /fix-feed %agent [our.bowl %graph-pull-hook] %poke -]
:- %pull-hook-action
!> [%add entity.res name.res]
!> ^- action:pull-hook
[%add entity.res res]
%_ $
cards (weld cards pokes)
-.old %9
-.old %10
==
?: ?=(%8 -.old)
$(-.old %9)
?: ?=(%7 -.old)
$(old [%8 (associations-2-to-3 associations.old) ~])
?: ?=(%6 -.old)

View File

@ -249,6 +249,7 @@
font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
font-weight: 400;
font-display: swap;
}
:root {
--red05: rgba(255,65,54,0.05);

View File

@ -29,6 +29,51 @@ The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port
You can either pass the `-P` flag to docker to map ports directly to the corresponding ports on the host, or map them individually with `-p` flags. For local testing the latter is often convenient, for instance to remap port 80 to an unprivileged port.
You should be able to use port mapping for most purposes but you can force Ames to use a custom port.
`--port=$AMES_PORT` can be passed as an argument to the `docker start` command. Passing `--port=13436` for example, would use port 13436.
### Examples
Creating a volume for ~sampel=palnet:
```
docker volume create sampel-palnet
```
Copying key to sampel-palnet's volume (assumes default docker location)
```
sudo cp ~/sampel-palnet.key /var/lib/docker/volumes/sampel-palnet/_data/sampel-palnet.key
```
Using that volume and launching ~sampel-palnet on host port 8080 with Ames talking on host port 27000:
```
docker run -d -p 8080:80 -p 27000:34343/udp --name sampel-palnet \
--mount type=volume,source=sampel-palnet,destination=/urbit \
tloncorp/urbit
```
Using host port 8088 with Ames talking on host port 23232 while forcing Ames to start internally on port 13436:
```
docker run -d -p 8088:80 -p 23232:13436/udp --name sampel-palnet \
--mount type=volume,source=sampel-palnet,destination=/urbit \
tloncorp/urbit --port=13436
```
### Getting and resetting the Landscape +code
This docker image includes tools for retrieving and resetting the Landscape login code belonging to the planet, for programmatic use so the container does not need a tty. These scripts can be called using `docker container exec`.
Getting the code:
```
$ docker container exec sampel-palnet /bin/get-urbit-code
sampel-sampel-sampel-sampel
```
Resetting the code:
```
$ docker container exec sampel-palnet /bin/reset-urbit-code
OK
```
Once the code has been reset the new code can be obtained from `/bin/get-urbit-code`.
## Extending
You likely do not want to extend this image. External applications which interact with Urbit do so primarily via an HTTP API, which should be exposed as described above. For containerized applications using Urbit, it is more appropriate to use a container orchestration service such as Docker Compose or Kubernetes to run Urbit alongside other containers which will interface with its API.

View File

@ -111,7 +111,7 @@ module.exports = {
]
}
},
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light)\/).*/
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light|@tlon\/indigo-react)\/).*/
},
{
test: /\.css$/i,

View File

@ -30,7 +30,7 @@ module.exports = {
]
}
},
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light)\/).*/
exclude: /node_modules\/(?!(@tlon\/indigo-dark|@tlon\/indigo-light|@tlon\/indigo-react)\/).*/
},
{
test: /\.css$/i,

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,8 @@
"@reach/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5",
"@tlon/indigo-dark": "^1.0.6",
"@tlon/indigo-light": "^1.0.6",
"@tlon/indigo-react": "^1.2.19",
"@tlon/indigo-light": "^1.0.7",
"@tlon/indigo-react": "^1.2.21",
"@tlon/sigil-js": "^1.4.3",
"@urbit/api": "file:../npm/api",
"any-ascii": "^0.1.7",
@ -88,7 +88,7 @@
"react-hot-loader": "^4.13.0",
"sass": "^1.32.5",
"sass-loader": "^8.0.2",
"typescript": "^3.9.7",
"typescript": "^4.2.4",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2"

View File

@ -4,6 +4,11 @@ import { dateToDa, decToUd } from '../lib/util';
import { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
import { BigInteger } from 'big-integer';
import { getParentIndex } from '../lib/notification';
import useHarkState from '../state/hark';
function getHarkSize() {
return useHarkState.getState().notifications.size ?? 0;
}
export class HarkApi extends BaseApi<StoreState> {
private harkAction(action: any): Promise<any> {
@ -172,10 +177,10 @@ export class HarkApi extends BaseApi<StoreState> {
}
async getMore(): Promise<boolean> {
const offset = this.store.state['notifications']?.size || 0;
const offset = getHarkSize();
const count = 3;
await this.getSubset(offset, count, false);
return offset === (this.store.state.notifications?.size || 0);
return offset === getHarkSize();
}
async getSubset(offset:number, count:number, isArchive: boolean) {

View File

@ -1,234 +0,0 @@
import bigInt, { BigInteger } from 'big-integer';
import { immerable } from 'immer';
interface NonemptyNode<V> {
n: [BigInteger, V];
l: MapNode<V>;
r: MapNode<V>;
}
type MapNode<V> = NonemptyNode<V> | null;
/**
* An implementation of ordered maps for JS
* Plagiarised wholesale from sys/zuse
*/
export class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
private root: MapNode<V> = null;
[immerable] = true;
size = 0;
constructor(initial: [BigInteger, V][] = []) {
initial.forEach(([key, val]) => {
this.set(key, val);
});
}
/**
* Retrieve an value for a key
*/
get(key: BigInteger): V | null {
const inner = (node: MapNode<V>) => {
if (!node) {
return node;
}
const [k, v] = node.n;
if (key.eq(k)) {
return v;
}
if (key.gt(k)) {
return inner(node.l);
} else {
return inner(node.r);
}
};
return inner(this.root);
}
/**
* Put an item by a key
*/
set(key: BigInteger, value: V): void {
const inner = (node: MapNode<V>) => {
if (!node) {
return {
n: [key, value],
l: null,
r: null
};
}
const [k] = node.n;
if (key.eq(k)) {
this.size--;
return {
...node,
n: [k, value]
};
}
if (key.gt(k)) {
const l = inner(node.l);
if (!l) {
throw new Error('invariant violation');
}
return {
...node,
l
};
}
const r = inner(node.r);
if (!r) {
throw new Error('invariant violation');
}
return { ...node, r };
};
this.size++;
this.root = inner(this.root);
}
/**
* Remove all entries
*/
clear() {
this.root = null;
}
/**
* Predicate testing if map contains key
*/
has(key: BigInteger): boolean {
const inner = (node: MapNode<V>) => {
if (!node) {
return false;
}
const [k] = node.n;
if (k.eq(key)) {
return true;
}
if (key.gt(k)) {
return inner(node.l);
}
return inner(node.r);
};
return inner(this.root);
}
/**
* Remove value associated with key, returning whether that key
* existed in the first place
*/
delete(key: BigInteger) {
const inner = (node: MapNode<V>): [boolean, MapNode<V>] => {
if (!node) {
return [false, null];
}
const [k] = node.n;
if (k.eq(key)) {
return [true, this.nip(node)];
}
if (key.gt(k)) {
const [bool, l] = inner(node.l);
return [
bool,
{
...node,
l
}
];
}
const [bool, r] = inner(node.r);
return [
bool,
{
...node,
r
}
];
};
const [ret, newRoot] = inner(this.root);
if(ret) {
this.size--;
}
this.root = newRoot;
return ret;
}
private nip(nod: NonemptyNode<V>): MapNode<V> {
const inner = (node: NonemptyNode<V>) => {
if (!node.l) {
return node.r;
}
if (!node.r) {
return node.l;
}
return {
...node.l,
r: inner(node.r)
};
};
return inner(nod);
}
peekLargest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>) => {
if(!node) {
return undefined;
}
if(node.l) {
return inner(node.l);
}
return node.n;
};
return inner(this.root);
}
peekSmallest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>) => {
if(!node) {
return undefined;
}
if(node.r) {
return inner(node.r);
}
return node.n;
};
return inner(this.root);
}
keys(): BigInteger[] {
const list = Array.from(this);
return list.map(([key]) => key);
}
forEach(f: (value: V, key: BigInteger) => void) {
const list = Array.from(this);
return list.forEach(([k,v]) => f(v,k));
}
[Symbol.iterator](): IterableIterator<[BigInteger, V]> {
const result: [BigInteger, V][] = [];
const inner = (node: MapNode<V>) => {
if (!node) {
return;
}
inner(node.l);
result.push(node.n);
inner(node.r);
};
inner(this.root);
let idx = 0;
return {
[Symbol.iterator]: this[Symbol.iterator],
next: (): IteratorResult<[BigInteger, V]> => {
if (idx < result.length) {
return { value: result[idx++], done: false };
}
return { done: true, value: null };
}
};
}
}

View File

@ -0,0 +1,18 @@
import React from "react";
export type SubmitHandler = () => Promise<any>;
interface IFormGroupContext {
addSubmit: (id: string, submit: SubmitHandler) => void;
onDirty: (id: string, touched: boolean) => void;
onErrors: (id: string, errors: boolean) => void;
submitAll: () => Promise<any>;
}
const fallback: IFormGroupContext = {
addSubmit: () => {},
onDirty: () => {},
onErrors: () => {},
submitAll: () => Promise.resolve(),
};
export const FormGroupContext = React.createContext(fallback);

View File

@ -1,6 +1,6 @@
import bigInt, { BigInteger } from 'big-integer';
import f from 'lodash/fp';
import { Unreads } from '@urbit/api';
import { Unreads, NotificationGraphConfig } from '@urbit/api';
export function getLastSeen(
unreads: Unreads,
@ -34,3 +34,13 @@ export function getNotificationCount(
.map(index => unread[index]?.notifications?.length || 0)
.reduce(f.add, 0);
}
export function isWatching(
config: NotificationGraphConfig,
graph: string,
index = "/"
) {
return !!config.watching.find(
watch => watch.graph === graph && watch.index === index
);
}

View File

@ -4,3 +4,7 @@ const ua = window.navigator.userAgent;
export const IS_IOS = ua.includes('iPhone');
export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome');
export const IS_ANDROID = ua.includes('Android');
export const IS_MOBILE = IS_IOS || IS_ANDROID;

View File

@ -1,13 +1,13 @@
import { Post, GraphNode, TextContent, Graph, NodeMap } from '@urbit/api';
import { Post, GraphNode, TextContent } from '@urbit/api';
import { buntPost } from '~/logic/lib/post';
import { unixToDa } from '~/logic/lib/util';
import { BigIntOrderedMap } from './BigIntOrderedMap';
import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import bigInt, { BigInteger } from 'big-integer';
export function newPost(
title: string,
body: string
): [BigInteger, NodeMap] {
): [BigInteger, any] {
const now = Date.now();
const nowDa = unixToDa(now);
const root: Post = {
@ -73,13 +73,16 @@ export function editPost(rev: number, noteId: BigInteger, title: string, body: s
}
export function getLatestRevision(node: GraphNode): [number, string, string, Post] {
const revs = node.children.get(bigInt(1));
const revs = node.children?.get(bigInt(1));
const empty = [1, '', '', buntPost()] as [number, string, string, Post];
if(!revs) {
return empty;
}
const [revNum, rev] = [...revs.children][0];
if(!rev) {
let revNum, rev;
if (revs?.children !== null) {
[revNum, rev] = [...revs.children][0];
}
if (!rev) {
return empty;
}
const [title, body] = rev.post.contents as TextContent[];
@ -88,18 +91,22 @@ export function getLatestRevision(node: GraphNode): [number, string, string, Pos
export function getLatestCommentRevision(node: GraphNode): [number, Post] {
const empty = [1, buntPost()] as [number, Post];
if (node.children.size <= 0) {
const childSize = node?.children?.size ?? 0;
if (childSize <= 0) {
return empty;
}
const [revNum, rev] = [...node.children][0];
if(!rev) {
let revNum, rev;
if (node?.children !== null) {
[revNum, rev] = [...node.children][0];
}
if (!rev) {
return empty;
}
return [revNum.toJSNumber(), rev.post];
}
export function getComments(node: GraphNode): GraphNode {
const comments = node.children.get(bigInt(2));
const comments = node.children?.get(bigInt(2));
if(!comments) {
return { post: buntPost(), children: new BigIntOrderedMap() };
}

View File

@ -1,4 +1,5 @@
import { TutorialProgress, Associations } from '@urbit/api';
import { Associations } from '@urbit/api';
import { TutorialProgress } from '~/types';
import { AlignX, AlignY } from '~/logic/lib/relativePosition';
import { Direction } from '~/views/components/Triangle';
@ -22,7 +23,7 @@ interface StepDetail {
alignY: AlignY | AlignY[];
offsetX: number;
offsetY: number;
arrow: Direction;
arrow?: Direction;
}
export function hasTutorialGroup(props: { associations: Associations }) {

View File

@ -15,7 +15,8 @@ function retrieve<T>(key: string, initial: T): T {
interface SetStateFunc<T> {
(t: T): T;
}
type SetState<T> = T | SetStateFunc<T>;
// See microsoft/typescript#37663 for filed bug
type SetState<T> = T extends any ? SetStateFunc<T> : never;
export function useLocalStorageState<T>(key: string, initial: T) {
const [state, _setState] = useState(() => retrieve(key, initial));

View File

@ -2,18 +2,14 @@ import React, {
useState,
ReactNode,
useCallback,
SyntheticEvent,
useMemo,
useEffect,
useRef
} from 'react';
import { Box } from '@tlon/indigo-react';
import { useOutsideClick } from './useOutsideClick';
import { ModalOverlay } from '~/views/components/ModalOverlay';
import { Portal } from '~/views/components/Portal';
import { ModalPortal } from '~/views/components/ModalPortal';
import { PropFunc } from '@urbit/api';
import { PropFunc } from '~/types';
type ModalFunc = (dismiss: () => void) => JSX.Element;
interface UseModalProps {

View File

@ -1,5 +1,5 @@
import { useRef } from 'react';
import { Primitive } from '@urbit/api';
import { Primitive } from '~/types';
export default function usePreviousValue<T extends Primitive>(value: T): T {
const prev = useRef<T | null>(null);

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useWaitForProps } from "./useWaitForProps";
import {unstable_batchedUpdates} from "react-dom";
export type IOInstance<I, P, O> = (
@ -10,7 +9,7 @@ export function useRunIO<I, O>(
io: (i: I) => Promise<O>,
after: (o: O) => void,
key: string
): () => Promise<void> {
): (i: I) => Promise<unknown> {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
const [output, setOutput] = useState<O | null>(null);

View File

@ -5,7 +5,7 @@ export function useStatelessAsyncClickable(
onClick: (e: MouseEvent) => Promise<void>,
name: string
) {
const [state, setState] = useState<ButtonState>('waiting');
const [state, setState] = useState<AsyncClickableState>('waiting');
const handleClick = useCallback(
async (e: MouseEvent) => {
try {

View File

@ -16,7 +16,7 @@ export interface IuseStorage {
upload: (file: File, bucket: string) => Promise<string>;
uploadDefault: (file: File) => Promise<string>;
uploading: boolean;
promptUpload: () => Promise<string | undefined>;
promptUpload: () => Promise<unknown>;
}
const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {

View File

@ -30,7 +30,7 @@ export const getModuleIcon = (mod: string) => {
}
if (mod === 'post') {
return 'Spaces';
return 'Dashboard';
}
return _.capitalize(mod);
@ -192,7 +192,10 @@ export function uxToHex(ux: string) {
export const hexToUx = (hex) => {
const ux = f.flow(
f.chunk(4),
f.map(x => _.dropWhile(x, y => y === 0).join('')),
// eslint-disable-next-line prefer-arrow-callback
f.map(x => _.dropWhile(x, function(y: unknown) {
return y === 0;
}).join('')),
f.join('.')
)(hex.split(''));
return `0x${ux}`;

View File

@ -7,6 +7,7 @@ import React, {
useEffect,
} from "react";
import usePreviousValue from "./usePreviousValue";
import {Primitive} from "~/types";
export interface VirtualContextProps {
save: () => void;
@ -49,7 +50,7 @@ export function useVirtualResizeState(s: boolean) {
return [state, setState] as const;
}
export function useVirtualResizeProp<T>(prop: T) {
export function useVirtualResizeProp(prop: Primitive) {
const { save, restore } = useVirtual();
const oldProp = usePreviousValue(prop)
@ -58,7 +59,7 @@ export function useVirtualResizeProp<T>(prop: T) {
}
useLayoutEffect(() => {
restore();
requestAnimationFrame(restore);
}, [prop]);

View File

@ -1,4 +1,5 @@
import { Associations, Workspace } from '@urbit/api';
import { Associations } from '@urbit/api';
import { Workspace } from '~/types';
export function getTitleFromWorkspace(
associations: Associations,

View File

@ -1,5 +1,4 @@
import _ from 'lodash';
import { StoreState } from '../../store/type';
import { StoreState } from '../store/type';
import { Cage } from '~/types/cage';
type LocalState = Pick<StoreState, 'connection'>;

View File

@ -1,5 +1,5 @@
import _ from 'lodash';
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import bigInt, { BigInteger } from "big-integer";
import useGraphState, { GraphState } from '../state/graph';
import { reduceState } from '../state/base';

View File

@ -5,16 +5,14 @@ import {
Group,
Tags,
GroupPolicy,
GroupPolicyDiff,
OpenPolicyDiff,
OpenPolicy,
InvitePolicyDiff,
InvitePolicy
} from '@urbit/api/groups';
import { Enc, PatpNoSig } from '@urbit/api';
import { Enc } from '@urbit/api';
import { resourceAsPath } from '../lib/util';
import useGroupState, { GroupState } from '../state/group';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
function decodeGroup(group: Enc<Group>): Group {
@ -125,9 +123,9 @@ const addMembers = (json: GroupUpdate, state: GroupState): GroupState => {
state.groups[resourcePath].members.add(member);
if (
'invite' in state.groups[resourcePath].policy &&
state.groups[resourcePath].policy.invite.pending.has(member)
state.groups[resourcePath].policy['invite'].pending.has(member)
) {
state.groups[resourcePath].policy.invite.pending.delete(member)
state.groups[resourcePath].policy['invite'].pending.delete(member);
}
}
}
@ -159,7 +157,7 @@ const addTag = (json: GroupUpdate, state: GroupState): GroupState => {
_.set(tags, tagAccessors, tagged);
}
return state;
}
};
const removeTag = (json: GroupUpdate, state: GroupState): GroupState => {
if ('removeTag' in json) {

View File

@ -1,18 +1,14 @@
import {
Notifications,
NotifIndex,
NotificationGraphConfig,
GroupNotificationsConfig,
UnreadStats,
Timebox
} from '@urbit/api';
import { makePatDa } from '~/logic/lib/util';
import _ from 'lodash';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import useHarkState, { HarkState } from '../state/hark';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
import bigInt, {BigInteger} from 'big-integer';
import {BigInteger} from 'big-integer';
export const HarkReducer = (json: any) => {
const data = _.get(json, 'harkUpdate', false);
@ -264,7 +260,7 @@ function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>)
if(!('graph' in index)) {
return state;
}
let unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
let unreads: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
f(unreads);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], unreads);
@ -278,7 +274,7 @@ function addNotificationToUnread(state: HarkState, index: NotifIndex, time: BigI
_.set(state.unreads.graph, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
{ time, index }
]
);
} else if ('group' in index) {
@ -287,7 +283,7 @@ function addNotificationToUnread(state: HarkState, index: NotifIndex, time: BigI
_.set(state.unreads.group, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
{ time, index }
]
);
}
@ -312,10 +308,10 @@ function removeNotificationFromUnread(state: HarkState, index: NotifIndex, time:
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'unreads' | 'last', f: (x: number) => number) {
if('graph' in index) {
const curr = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
const curr: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr));
} else if('group' in index) {
const curr = _.get(state.unreads.group, [index.group.group, statField], 0);
const curr: any = _.get(state.unreads.group, [index.group.group, statField], 0);
_.set(state.unreads.group, [index.group.group, statField], f(curr));
}
}

View File

@ -18,7 +18,7 @@ export default class LaunchReducer {
]);
}
const weatherData: WeatherState = _.get(json, 'weather', false);
const weatherData: WeatherState | boolean | Record<string, never> = _.get(json, 'weather', false);
if (weatherData) {
useLaunchState.getState().set(state => {
state.weather = weatherData;

View File

@ -1,7 +1,8 @@
import _ from 'lodash';
import useSettingsState, { SettingsState } from "~/logic/state/settings";
import { SettingsUpdate } from '@urbit/api/dist/settings';
import useSettingsState, { SettingsState } from '~/logic/state/settings';
import { SettingsUpdate } from '@urbit/api/settings';
import { reduceState } from '../state/base';
import { string } from 'prop-types';
export default class SettingsReducer {
reduce(json: any) {
@ -40,21 +41,21 @@ export default class SettingsReducer {
return state;
}
putEntry(json: SettingsUpdate, state: SettingsState): SettingsState {
const data = _.get(json, 'put-entry', false);
putEntry(json: SettingsUpdate, state: any): SettingsState {
const data: Record<string, string> = _.get(json, 'put-entry', false);
if (data) {
if (!state[data["bucket-key"]]) {
state[data["bucket-key"]] = {};
if (!state[data['bucket-key']]) {
state[data['bucket-key']] = {};
}
state[data["bucket-key"]][data["entry-key"]] = data.value;
state[data['bucket-key']][data['entry-key']] = data.value;
}
return state;
}
delEntry(json: SettingsUpdate, state: SettingsState): SettingsState {
delEntry(json: SettingsUpdate, state: any): SettingsState {
const data = _.get(json, 'del-entry', false);
if (data) {
delete state[data["bucket-key"]][data["entry-key"]];
delete state[data['bucket-key']][data['entry-key']];
}
return state;
}
@ -76,7 +77,7 @@ export default class SettingsReducer {
return state;
}
getEntry(json: any, state: SettingsState) {
getEntry(json: any, state: any) {
const bucketKey = _.get(json, 'bucket-key', false);
const entryKey = _.get(json, 'entry-key', false);
const entry = _.get(json, 'entry', false);

View File

@ -1,8 +1,10 @@
import produce from "immer";
import produce, { setAutoFreeze } from "immer";
import { compose } from "lodash/fp";
import create, { State, UseStore } from "zustand";
import { persist, devtools } from "zustand/middleware";
setAutoFreeze(false);
export const stateSetter = <StateType>(
fn: (state: StateType) => void,

View File

@ -15,7 +15,7 @@ export interface HarkState extends BaseState<HarkState> {
notifications: BigIntOrderedMap<Timebox>;
notificationsCount: number;
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
notificationsGroupConfig: []; // TODO type this
notificationsGroupConfig: string[];
unreads: Unreads;
};

View File

@ -9,7 +9,7 @@ export interface LaunchState extends BaseState<LaunchState> {
tiles: {
[app: string]: Tile;
},
weather: WeatherState | null,
weather: WeatherState | null | Record<string, never> | boolean,
userLocation: string | null;
baseHash: string | null;
};

View File

@ -1,4 +1,5 @@
import { useCallback } from 'react';
import _ from 'lodash';
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
import { BaseState, createState } from "./base";
@ -18,6 +19,11 @@ export function useAssocForGroup(group: string) {
return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group]));
}
export function useGraphsForGroup(group: string) {
const graphs = useMetadataState(s => s.associations.graph);
return _.pickBy(graphs, (a: Association) => a.group === group);
}
const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
// preview: async (group): Promise<MetadataUpdatePreview> => {

View File

@ -58,7 +58,7 @@ const useSettingsState = createState<SettingsState>('Settings', {
categories: leapCategories,
},
tutorial: {
seen: false,
seen: true,
joined: undefined
}
});

View File

@ -3,10 +3,8 @@ import _ from 'lodash';
import BaseStore from './base';
import InviteReducer from '../reducers/invite-update';
import MetadataReducer from '../reducers/metadata-update';
import LocalReducer from '../reducers/local';
import { StoreState } from './type';
import { Timebox } from '@urbit/api';
import { Cage } from '~/types/cage';
import S3Reducer from '../reducers/s3-update';
import { GraphReducer } from '../reducers/graph-update';
@ -17,8 +15,6 @@ import LaunchReducer from '../reducers/launch-update';
import ConnectionReducer from '../reducers/connection';
import SettingsReducer from '../reducers/settings-update';
import GcpReducer from '../reducers/gcp-reducer';
import { OrderedMap } from '../lib/OrderedMap';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import { GroupViewReducer } from '../reducers/group-view';
import { unstable_batchedUpdates } from 'react-dom';

View File

@ -53,6 +53,7 @@ const Root = withState(styled.div`
}
display: flex;
flex-flow: column nowrap;
touch-action: none;
* {
scrollbar-width: thin;

View File

@ -188,7 +188,7 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
<LoadingSpinner />
) : (
<Icon
icon='Links'
icon='Attachment'
width='16'
height='16'
onClick={() =>

View File

@ -1,4 +1,5 @@
/* eslint-disable max-lines-per-function */
import bigInt from 'big-integer';
import React, {
useState,
useEffect,
@ -19,7 +20,8 @@ import {
writeText,
useShowNickname,
useHideAvatar,
useHovering
useHovering,
daToUnix
} from '~/logic/lib/util';
import {
Group,
@ -65,7 +67,7 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
<Rule borderColor='lightGray' />
<Text
gray
flexShrink='0'
flexShrink={0}
whiteSpace='nowrap'
textAlign='center'
fontSize={0}
@ -107,7 +109,7 @@ export const UnreadMarker = React.forwardRef(
<Text
color='blue'
fontSize={0}
flexShrink='0'
flexShrink={0}
whiteSpace='nowrap'
textAlign='center'
px={2}
@ -168,7 +170,7 @@ const MessageActions = ({ api, onReply, association, history, msg, group }) => {
width='auto'
alignY='top'
alignX='right'
flexShrink={'0'}
flexShrink={0}
offsetY={8}
offsetX={-24}
options={
@ -295,15 +297,20 @@ class ChatMessage extends Component<ChatMessageProps> {
);
}
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const nextDate = nextMsg ? (
daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null;
const dayBreak =
nextMsg &&
new Date(msg['time-sent']).getDate() !==
new Date(nextMsg['time-sent']).getDate();
new Date(date).getDate() !==
new Date(nextDate).getDate();
const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
const timestamp = moment
.unix(msg['time-sent'] / 1000)
.unix(date / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm');
const messageProps = {
@ -339,7 +346,7 @@ class ChatMessage extends Component<ChatMessageProps> {
style={style}
>
{dayBreak && !isLastRead ? (
<DayBreak when={msg['time-sent']} shimTop={renderSigil} />
<DayBreak when={date} shimTop={renderSigil} />
) : null}
{renderSigil ? (
<MessageWrapper {...messageProps}>
@ -357,7 +364,7 @@ class ChatMessage extends Component<ChatMessageProps> {
association={association}
api={api}
dayBreak={dayBreak}
when={msg['time-sent']}
when={date}
ref={unreadMarkerRef}
/>
) : null}
@ -387,8 +394,10 @@ export const MessageAuthor = ({
const dark = theme === 'dark' || (theme === 'auto' && osDark);
const contacts = useContactState((state) => state.contacts);
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const datestamp = moment
.unix(msg['time-sent'] / 1000)
.unix(date / 1000)
.format(DATESTAMP_FORMAT);
const contact =
((msg.author === window.ship && showOurContact) ||

View File

@ -46,7 +46,7 @@ const ScrollbarLessBox = styled(Box)`
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
export default function LaunchApp(props) {
const connection = { props };
const { connection } = props;
const baseHash = useLaunchState(state => state.baseHash);
const [hashText, setHashText] = useState(baseHash);
const [exitingTut, setExitingTut] = useState(false);
@ -220,7 +220,7 @@ export default function LaunchApp(props) {
<NewGroup {...props} />
</ModalButton>
<ModalButton
icon="Boot"
icon="BootNode"
bg="washedGray"
color="black"
text="Join Group"

View File

@ -145,8 +145,7 @@ function ContentSummary({ icon, name, author, to }) {
export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
const { contents } = post;
const idx = index.slice(1).split("/");
const { group, resource } = association;
const url = getNodeUrl(mod, hidden, group, resource, index);
const url = getNodeUrl(mod, hidden, association?.group, association?.resource, index);
if (mod === "link" && idx.length === 1) {
const [{ text: title }] = contents;
return (
@ -296,7 +295,7 @@ export function GraphNotification(props: {
dm,
singleAuthor
);
const groupAssociation = useAssocForGroup(association.group);
const groupAssociation = useAssocForGroup(association?.group);
const groups = useGroupState((state) => state.groups);
const onClick = useCallback(() => {
@ -307,13 +306,12 @@ export function GraphNotification(props: {
)
) {
const first = contents[0];
const { group, resource } = association;
history.push(
getNodeUrl(
index.module,
groups[association.group]?.hidden,
groups[association?.group]?.hidden,
group,
resource,
association?.resource,
first.index
)
);
@ -328,7 +326,7 @@ export function GraphNotification(props: {
authorsInHeader ||
index.description === "note" ||
index.description === "link";
const channelTitle = dm ? undefined : association.metadata.title ?? graph;
const channelTitle = dm ? undefined : association?.metadata?.title ?? graph;
const groupTitle = groupAssociation?.metadata?.title;
return (
@ -349,7 +347,7 @@ export function GraphNotification(props: {
description={index.description}
index={contents?.[0].index}
association={association}
hidden={groups[association.group]?.hidden}
hidden={groups[association?.group]?.hidden}
/>
{contents.length > 4 && (
<Text mb="2" gray>

View File

@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { Row, Box } from "@tlon/indigo-react";
import { Row, Box, Icon } from "@tlon/indigo-react";
import _ from "lodash";
import {
GraphNotificationContents,
@ -19,6 +19,7 @@ import { GraphNotification } from "./graph";
import { BigInteger } from "big-integer";
import { useHovering } from "~/logic/lib/util";
import useHarkState from "~/logic/state/hark";
import {IS_MOBILE} from "~/logic/lib/platform";
interface NotificationProps {
notification: IndexedNotification;
@ -102,39 +103,30 @@ export function NotificationWrapper(props: {
}
borderRadius={2}
display="grid"
gridTemplateColumns={["1fr", "1fr 200px"]}
gridTemplateColumns={["1fr 24px", "1fr 200px"]}
gridTemplateRows="auto"
gridTemplateAreas={["'header' 'main'", "'header actions' 'main main'"]}
gridTemplateAreas="'header actions' 'main main'"
p={2}
m={2}
{...bind}
>
{children}
<Row
display={["none", "flex"]}
alignItems="center"
alignItems="flex-start"
gapX="2"
gridArea="actions"
justifyContent="flex-end"
opacity={[1, hovering ? 1 : 0]}
opacity={[1, (hovering || IS_MOBILE) ? 1 : 0]}
>
{time && notification && (
<>
<StatelessAsyncAction
name={changeMuteDesc}
onClick={onChangeMute}
backgroundColor="transparent"
>
{changeMuteDesc}
</StatelessAsyncAction>
<StatelessAsyncAction
name={time.toString()}
onClick={onArchive}
backgroundColor="transparent"
>
Dismiss
</StatelessAsyncAction>
</>
<StatelessAsyncAction
name={time.toString()}
borderRadius={1}
onClick={onArchive}
backgroundColor="white"
>
<Icon lineHeight="24px" size={16} icon="X" />
</StatelessAsyncAction>
)}
</Row>
</Box>

View File

@ -3,7 +3,7 @@ import _ from 'lodash';
import { Link, Switch, Route } from 'react-router-dom';
import Helmet from 'react-helmet';
import { Box, Col, Text, Row } from '@tlon/indigo-react';
import { Box, Icon, Col, Text, Row } from '@tlon/indigo-react';
import { Body } from '~/views/components/Body';
import { PropFunc } from '~/types/util';
@ -15,6 +15,7 @@ import { useTutorialModal } from '~/views/components/useTutorialModal';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
import useGroupState from '~/logic/state/group';
import {StatelessAsyncAction} from '~/views/components/StatelessAsyncAction';
const baseUrl = '/~notifications';
@ -46,8 +47,8 @@ export default function NotificationsScreen(props: any): ReactElement {
const onSubmit = async ({ groups } : NotificationFilter) => {
setFilter({ groups });
};
const onReadAll = useCallback(() => {
props.api.hark.readAll();
const onReadAll = useCallback(async () => {
await props.api.hark.readAll();
}, []);
const groupFilterDesc =
filter.groups.length === 0
@ -81,53 +82,26 @@ export default function NotificationsScreen(props: any): ReactElement {
borderBottomColor="lightGray"
>
<Text ref={anchorRef}>Notifications</Text>
<Text fontWeight="bold" fontSize="2" lineHeight="1" ref={anchorRef}>
Notifications
</Text>
<Row
justifyContent="space-between"
gapX="3"
>
<Box
mr="1"
<StatelessAsyncAction
overflow="hidden"
color="black"
backgroundColor="white"
onClick={onReadAll}
cursor="pointer"
>
<Text mr="1" color="blue">
Mark All Read
</Text>
</Box>
<Dropdown
alignX="right"
alignY="top"
options={
<Col
p="2"
backgroundColor="white"
border={1}
borderRadius={1}
borderColor="lightGray"
gapY="2"
>
<FormikOnBlur
initialValues={filter}
onSubmit={onSubmit}
>
<GroupSearch
id="groups"
label="Filter Groups"
caption="Only show notifications from this group"
/>
</FormikOnBlur>
</Col>
}
>
Mark All Read
</StatelessAsyncAction>
<Link to="/~settings#notifications">
<Box>
<Text mr="1" gray>
Filter:
</Text>
<Text>{groupFilterDesc}</Text>
<Icon lineHeight="1" icon="Adjust" />
</Box>
</Dropdown>
</Link>
</Row>
</Row>
{!view && <Inbox

View File

@ -18,7 +18,7 @@ import { GroupLink } from "~/views/components/GroupLink";
import GlobalApi from "~/logic/api/global";
import { getModuleIcon } from "~/logic/lib/util";
import useMetadataState from "~/logic/state/metadata";
import { Association, resourceFromPath } from "@urbit/api";
import { Association, resourceFromPath, GraphNode } from "@urbit/api";
import { Link } from "react-router-dom";
import useGraphState from "~/logic/state/graph";
import { GraphNodeContent } from "../notifications/graph";
@ -51,7 +51,7 @@ function GraphPermalink(
const { full = false, showOurContact, pending, link, graph, group, index, api, transcluded } = props;
const { ship, name } = resourceFromPath(graph);
const node = useGraphState(
useCallback((s) => s.looseNodes?.[`${ship.slice(1)}/${name}`]?.[index], [
useCallback((s) => s.looseNodes?.[`${ship.slice(1)}/${name}`]?.[index] as GraphNode, [
graph,
index,
])
@ -63,7 +63,7 @@ function GraphPermalink(
])
);
useVirtualResizeProp(node)
useVirtualResizeProp(!!node)
useEffect(() => {
(async () => {
if (pending || !index) {

View File

@ -73,14 +73,14 @@ export function Note(props: NoteProps & RouteComponentProps) {
if (window.ship === note?.post?.author) {
adminLinks.push(
<Link to={`${baseUrl}/edit`}>
<Action>Update</Action>
<Action backgroundColor="white">Update</Action>
</Link>
)
};
if (window.ship === note?.post?.author || ourRole === "admin") {
adminLinks.push(
<Action destructive onClick={deletePost}>
<Action backgroundColor="white" destructive onClick={deletePost}>
Delete
</Action>
)

View File

@ -44,7 +44,7 @@ export function PostForm(props: PostFormProps) {
validateOnBlur
>
<Form style={{ display: 'contents' }}>
<Row flexShrink='0' flexDirection={['column-reverse', 'row']} mb={4} gapX={4} justifyContent='space-between'>
<Row flexShrink={0} flexDirection={['column-reverse', 'row']} mb={4} gapX={4} justifyContent='space-between'>
<Input maxWidth='40rem' width='100%' flexShrink={[0, 1]} placeholder="Post Title" id="title" />
<Row flexDirection={['column', 'row']} mb={[4,0]}>
<AsyncButton

View File

@ -0,0 +1,135 @@
import React, { useState, useEffect } from "react";
import {
Box,
Text,
Icon,
ManagedToggleSwitchField,
StatelessToggleSwitchField,
Col,
Center,
} from "@tlon/indigo-react";
import _ from "lodash";
import useMetadataState, { useGraphsForGroup } from "~/logic/state/metadata";
import { Association, resourceFromPath } from "@urbit/api";
import { MetadataIcon } from "~/views/landscape/components/MetadataIcon";
import useGraphState from "~/logic/state/graph";
import { useField } from "formik";
import useHarkState from "~/logic/state/hark";
import { getModuleIcon } from "~/logic/lib/util";
import {isWatching} from "~/logic/lib/hark";
export function GroupChannelPicker(props: {}) {
const associations = useMetadataState((s) => s.associations);
return (
<Col gapY="3">
{_.map(associations.groups, (assoc: Association, group: string) => (
<GroupWithChannels key={group} association={assoc} />
))}
</Col>
);
}
function GroupWithChannels(props: { association: Association }) {
const { association } = props;
const { metadata } = association;
const groupWatched = useHarkState((s) =>
s.notificationsGroupConfig.includes(association.group)
);
const [{ value }, meta, { setValue }] = useField(
`groups["${association.group}"]`
);
const onChange = () => {
setValue(!value);
};
useEffect(() => {
setValue(groupWatched);
}, []);
const graphs = useGraphsForGroup(association.group);
const joinedGraphs = useGraphState((s) => s.graphKeys);
const joinedGroupGraphs = _.pickBy(graphs, (_, graph: string) => {
const { ship, name } = resourceFromPath(graph);
return joinedGraphs.has(`${ship.slice(1)}/${name}`);
});
const [open, setOpen] = useState(false);
return (
<Box
display="grid"
gridTemplateColumns="24px 24px 1fr 24px 24px"
gridTemplateRows="auto"
gridGap="2"
gridTemplateAreas="'arrow icon title graphToggle groupToggle'"
>
{Object.keys(joinedGroupGraphs).length > 0 && (
<Center
cursor="pointer"
onClick={() => setOpen((o) => !o)}
gridArea="arrow"
>
<Icon icon={open ? "ChevronSouth" : "ChevronEast"} />
</Center>
)}
<MetadataIcon
size="24px"
gridArea="icon"
metadata={association.metadata}
/>
<Box gridArea="title">
<Text>{metadata.title}</Text>
</Box>
<Box gridArea="groupToggle">
<StatelessToggleSwitchField selected={value} onChange={onChange} />
</Box>
{open &&
_.map(joinedGroupGraphs, (a: Association, graph: string) => (
<Channel key={graph} association={a} />
))}
</Box>
);
}
function Channel(props: { association: Association }) {
const { association } = props;
const { metadata } = association;
const watching = useHarkState((s) => {
const config = s.notificationsGraphConfig;
return isWatching(config, association.resource);
});
const [{ value }, meta, { setValue }] = useField(
`graph["${association.resource}"]`
);
useEffect(() => {
setValue(watching);
}, [watching]);
const onChange = () => {
setValue(!value);
};
const icon = getModuleIcon(metadata.config?.graph);
return (
<>
<Center gridColumn="2">
<Icon icon={icon} />
</Center>
<Box gridColumn="3">
<Text> {metadata.title}</Text>
</Box>
<Box gridColumn="4">
<StatelessToggleSwitchField selected={value} onChange={onChange} />
</Box>
</>
);
}

View File

@ -10,11 +10,19 @@ import GlobalApi from "~/logic/api/global";
import useHarkState from "~/logic/state/hark";
import _ from "lodash";
import {AsyncButton} from "~/views/components/AsyncButton";
import {GroupChannelPicker} from "./GroupChannelPicker";
import {isWatching} from "~/logic/lib/hark";
interface FormSchema {
mentions: boolean;
dnd: boolean;
watchOnSelf: boolean;
graph: {
[rid: string]: boolean;
};
groups: {
[rid: string]: boolean;
}
}
export function NotificationPreferences(props: {
@ -23,6 +31,7 @@ export function NotificationPreferences(props: {
const { api } = props;
const dnd = useHarkState(state => state.doNotDisturb);
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const groupConfig = useHarkState(s => s.notificationsGroupConfig);
const initialValues = {
mentions: graphConfig.mentions,
dnd: dnd,
@ -41,6 +50,16 @@ export function NotificationPreferences(props: {
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
promises.push(api.hark.setDoNotDisturb(values.dnd))
}
_.forEach(values.graph, (listen: boolean, graph: string) => {
if(listen !== isWatching(graphConfig, graph)) {
promises.push(api.hark[listen ? "listenGraph" : "ignoreGraph"](graph, "/"))
}
});
_.forEach(values.groups, (listen: boolean, group: string) => {
if(listen !== groupConfig.includes(group)) {
promises.push(api.hark[listen ? "listenGroup" : "ignoreGroup"](group));
}
});
await Promise.all(promises);
actions.setStatus({ success: null });
@ -81,6 +100,15 @@ export function NotificationPreferences(props: {
id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined"
/>
<Col gapY="3">
<Text lineHeight="tall">
Activity
</Text>
<Text gray>
Set which groups will send you notifications.
</Text>
<GroupChannelPicker />
</Col>
<AsyncButton primary width="fit-content">
Save
</AsyncButton>

View File

@ -107,7 +107,7 @@ export default function SettingsScreen(props: any) {
</Text>
<Col>
<SidebarItem
icon='Inbox'
icon='Notifications'
text='Notifications'
hash='notifications'
/>

View File

@ -115,8 +115,8 @@ export function ChipInput(props: ChipInputProps): ReactElement {
<Input
width="auto"
height="24px"
flexShrink="1"
flexGrow="1"
flexShrink={1}
flexGrow={1}
pl="0"
ref={inputRef}
onChange={onChange}

View File

@ -25,6 +25,7 @@ interface DropdownProps {
offsetY?: number;
width?: string;
dropWidth?: string;
flexShrink?: number;
}
const ClickBox = styled(Box)`
@ -39,7 +40,7 @@ const DropdownOptions = styled(Box)`
`;
export function Dropdown(props: DropdownProps): ReactElement {
const { children, options, offsetX = 0, offsetY = 0 } = props;
const { children, options, offsetX = 0, offsetY = 0, flexShrink = 1 } = props;
const dropdownRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null);
const { pathname } = useLocation();
@ -47,6 +48,9 @@ export function Dropdown(props: DropdownProps): ReactElement {
const [coords, setCoords] = useState({});
const updatePos = useCallback(() => {
if(!anchorRef.current) {
return;
}
const newCoords = getRelativePosition(anchorRef.current, props.alignX, props.alignY, offsetX, offsetY);
if(newCoords) {
setCoords(newCoords);
@ -86,7 +90,7 @@ export function Dropdown(props: DropdownProps): ReactElement {
}, []);
return (
<Box flexShrink={props?.flexShrink ? props.flexShrink : 1} position={open ? 'relative' : 'static'} minWidth='0' width={props?.width ? props.width : 'auto'}>
<Box flexShrink={flexShrink} position={open ? 'relative' : 'static'} minWidth='0' width={props?.width ? props.width : 'auto'}>
<ClickBox width='100%' ref={anchorRef} onClick={onOpen}>
{children}
</ClickBox>

View File

@ -0,0 +1,175 @@
import React, {
ReactNode,
useEffect,
useCallback,
useState,
useMemo,
} from "react";
import { Button, Box, Row, Col } from "@tlon/indigo-react";
import _ from "lodash";
import { useFormikContext } from "formik";
import { PropFunc } from "~/types";
import { FormGroupContext, SubmitHandler } from "~/logic/lib/formGroup";
import { StatelessAsyncButton } from "./StatelessAsyncButton";
import { Prompt } from "react-router-dom";
import { usePreventWindowUnload } from "~/logic/lib/util";
export function useFormGroupContext(id: string) {
const ctx = React.useContext(FormGroupContext);
const addSubmit = useCallback(
(submit: SubmitHandler) => {
ctx.addSubmit(id, submit);
},
[ctx.addSubmit, id]
);
const onDirty = useCallback(
(dirty: boolean) => {
ctx.onDirty(id, dirty);
},
[ctx.onDirty, id]
);
const onErrors = useCallback(
(errors: boolean) => {
ctx.onErrors(id, errors);
},
[ctx.onErrors, id]
);
const addReset = useCallback(
(r: () => void) => {
ctx.addReset(id, r);
},
[ctx.addReset, id]
);
return {
onDirty,
addSubmit,
onErrors,
addReset,
};
}
export function FormGroupChild(props: { id: string }) {
const { id } = props;
const { addSubmit, onDirty, onErrors, addReset } = useFormGroupContext(id);
const {
submitForm,
dirty,
errors,
resetForm,
initialValues,
values
} = useFormikContext();
useEffect(() => {
async function submit() {
await submitForm();
resetForm({ touched: {}, values });
}
addSubmit(submit);
}, [submitForm, values]);
useEffect(() => {
onDirty(dirty);
}, [dirty, onDirty]);
useEffect(() => {
onErrors(_.keys(_.pickBy(errors, (s) => !!s)).length > 0);
}, [errors, onErrors]);
useEffect(() => {
const reset = () => {
resetForm({ errors: {}, touched: {}, values: initialValues, status: {} });
};
addReset(reset);
}, [resetForm, initialValues]);
return <Box display="none" />;
}
export function FormGroup(props: { onReset?: () => void; } & PropFunc<typeof Box>) {
const { children, onReset, ...rest } = props;
const [submits, setSubmits] = useState({} as { [id: string]: SubmitHandler });
const [resets, setResets] = useState({} as Record<string, () => void>);
const [dirty, setDirty] = useState({} as Record<string, boolean>);
const [errors, setErrors] = useState({} as Record<string, boolean>);
const addSubmit = useCallback((id: string, s: SubmitHandler) => {
setSubmits((ss) => ({ ...ss, [id]: s }));
}, []);
const resetAll = useCallback(() => {
_.map(resets, (r) => r());
onReset && onReset();
}, [resets, onReset]);
const submitAll = useCallback(async () => {
await Promise.all(
_.map(
_.pickBy(submits, (_v, k) => dirty[k]),
(f) => f()
)
);
}, [submits, dirty]);
const onDirty = useCallback(
(id: string, t: boolean) => {
setDirty((ts) => ({ ...ts, [id]: t }));
},
[setDirty]
);
const onErrors = useCallback((id: string, e: boolean) => {
setErrors((es) => ({ ...es, [id]: e }));
}, []);
const addReset = useCallback((id: string, reset: () => void) => {
setResets((rs) => ({ ...rs, [id]: reset }));
}, []);
const context = { addSubmit, submitAll, onErrors, onDirty, addReset };
const hasErrors = useMemo(
() => _.keys(_.pickBy(errors, (s) => !!s)).length > 0,
[errors]
);
const isDirty = useMemo(
() => _.keys(_.pickBy(dirty, _.identity)).length > 0,
[dirty]
);
usePreventWindowUnload(isDirty);
return (
<Box {...rest} position="relative">
<Prompt
when={isDirty}
message="Are you sure you want to leave? You have unsaved changes"
/>
<FormGroupContext.Provider value={context}>
{children}
</FormGroupContext.Provider>
<Row
justifyContent="flex-end"
width="100%"
position="sticky"
bottom="0px"
p="3"
gapX="2"
backgroundColor="white"
borderTop="1"
borderTopColor="washedGray"
>
<Button onClick={resetAll}>Cancel</Button>
<StatelessAsyncButton
onClick={submitAll}
disabled={hasErrors || !isDirty}
primary
>
Save Changes
</StatelessAsyncButton>
</Row>
</Box>
);
}

View File

@ -64,7 +64,7 @@ function Elbow(
>
<Box
border="2px solid"
borderRadius={2}
borderRadius={3}
borderColor={color}
position="absolute"
left="0px"

View File

@ -83,7 +83,7 @@ const StatusBar = (props) => {
onClick={() => history.push('/')}
{...props}
>
<Icon icon='Spaces' color='black' />
<Icon icon='Dashboard' color='black' />
</Button>
<StatusBarItem float={floatLeap} mr={2} onClick={() => toggleOmnibox()}>
{!doNotDisturb && (notificationsCount > 0 || invites.length > 0) && (

View File

@ -115,6 +115,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
* A map of child refs, used to calculate scroll position
*/
private childRefs = new BigIntOrderedMap<HTMLElement>();
/**
* A set of child refs which have been unmounted
*/
private orphans = new Set<string>();
/**
* If saving, the bottommost visible element that we pin our scroll to
*/
@ -140,6 +144,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
private scrollRef: HTMLElement | null = null;
private cleanupRefInterval: NodeJS.Timeout | null = null;
private initScroll: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
@ -157,6 +165,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 400) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
}
componentDidMount() {
@ -164,8 +173,27 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.resetScroll();
this.loadTop();
this.loadBottom();
this.cleanupRefInterval = setInterval(this.cleanupRefs, 5000);
this.initScroll = setTimeout(() => {
log('scroll', 'initialised scroll');
this.restore();
this.initScroll = null;
}, 100);
}
cleanupRefs = () => {
if(this.saveDepth > 0) {
return;
}
[...this.orphans].forEach(o => {
const index = bigInt(o);
this.childRefs.delete(index);
});
this.orphans.clear();
};
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
@ -199,6 +227,12 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
if(this.cleanupRefInterval) {
clearInterval(this.cleanupRefInterval);
}
if(this.initScroll) {
clearTimeout(this.initScroll);
}
}
startOffset() {
@ -237,9 +271,6 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}, () => {
requestAnimationFrame(() => {
this.restore();
requestAnimationFrame(() => {
});
});
});
}
@ -339,6 +370,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
// bail if we're going to adjust scroll anyway
return;
}
if(this.initScroll) {
clearTimeout(this.initScroll);
this.initScroll = null;
}
if(this.saveDepth > 0) {
log('bail', 'deep scroll queue');
return;
@ -394,8 +429,15 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('bail', 'Deep restore');
return;
}
if(this.initScroll) {
log('bail', 'still initialising scroll');
return;
}
const ref = this.childRefs.get(this.savedIndex)!;
let ref = this.childRefs.get(this.savedIndex)
if(!ref) {
return;
}
const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop);
@ -435,12 +477,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!this.window || this.savedIndex) {
return;
}
this.saveDepth++;
if(this.saveDepth !== 1) {
if(this.saveDepth !== 0) {
console.log('bail', 'deep save');
return;
}
this.saveDepth++;
let bottomIndex: BigInteger | null = null;
const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop;
@ -472,10 +515,9 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
setRef = (element: HTMLElement | null, index: BigInteger) => {
if(element) {
this.childRefs.set(index, element);
this.orphans.delete(index.toString());
} else {
setTimeout(() => {
this.childRefs.delete(index);
});
this.orphans.add(index.toString());
}
}

View File

@ -65,7 +65,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='Inbox'
icon='Notifications'
mr='2'
size='18px'
color={iconFill}
@ -85,7 +85,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='SignOut'
icon='LogOut'
mr='2'
size='18px'
color={iconFill}
@ -119,7 +119,7 @@ export class OmniboxResult extends Component {
<Icon
display='inline-block'
verticalAlign='middle'
icon='Inbox'
icon='Notifications'
mr='2'
size='18px'
color={iconFill}

View File

@ -2,6 +2,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/~landscape/fonts/inter-regular.woff2") format("woff2"),
url("https://media.urbit.org/fonts/Inter-Regular.woff2") format("woff2");
}
@ -10,6 +11,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("https://media.urbit.org/fonts/Inter-Medium.woff2") format("woff2");
}
@ -17,6 +19,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("https://media.urbit.org/fonts/Inter-SemiBold.woff2") format("woff2");
}
@ -24,6 +27,7 @@
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/~landscape/fonts/inter-italic.woff2") format("woff2"),
url("https://media.urbit.org/fonts/Inter-Italic.woff2") format("woff2");
}
@ -32,6 +36,7 @@
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/~landscape/fonts/inter-bold.woff2") format("woff2"),
url("https://media.urbit.org/fonts/Inter-Bold.woff2") format("woff2");
}
@ -39,6 +44,7 @@
font-family: 'Inter';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/~landscape/fonts/inter-bolditalic.woff2") format("woff2"),
url("https://media.urbit.org/fonts/Inter-BoldItalic.woff2") format("woff2");
}
@ -48,6 +54,7 @@
src: url("/~landscape/fonts/sourcecodepro-extralight.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-extralight.woff");
font-weight: 200;
font-display: swap;
}
@font-face {
@ -55,6 +62,7 @@
src: url("/~landscape/fonts/sourcecodepro-light.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-light.woff");
font-weight: 300;
font-display: swap;
}
@font-face {
@ -62,6 +70,7 @@
src: url("/~landscape/fonts/sourcecodepro-regular.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
font-weight: 400;
font-display: swap;
}
@font-face {
@ -69,6 +78,7 @@
src: url("(/~landscape/fonts/sourcecodepro-medium.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-medium.woff");
font-weight: 500;
font-display: swap;
}
@font-face {
@ -76,6 +86,7 @@
src: url("/~landscape/fonts/sourcecodepro-semibold.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-semibold.woff");
font-weight: 600;
font-display: swap;
}
@font-face {
@ -83,5 +94,6 @@
src: url("/~landscape/fonts/sourcecodepro-bold.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-bold.woff");
font-weight: 700;
font-display: swap;
}

View File

@ -15,6 +15,7 @@ import GlobalApi from '~/logic/api/global';
import { resourceFromPath } from '~/logic/lib/group';
import { FormSubmit } from '~/views/components/FormSubmit';
import { ChannelWritePerms } from '../ChannelWritePerms';
import {FormGroupChild} from '~/views/components/FormGroup';
function PermissionsSummary(props: {
writersSize: number;
@ -158,7 +159,8 @@ export function GraphPermissions(props: GraphPermissionsProps) {
onSubmit={onSubmit}
>
<Form style={{ display: 'contents' }}>
<Col mt="4" flexShrink={0} gapY="5">
<FormGroupChild id="permissions" />
<Col mx="4" mt="4" flexShrink={0} gapY="5">
<Col gapY="1" mt="0">
<Text id="permissions" fontWeight="bold" fontSize="2">
Permissions
@ -187,7 +189,6 @@ export function GraphPermissions(props: GraphPermissionsProps) {
caption="If enabled, all members of the group can comment on this channel"
/>
)}
<FormSubmit>Update Permissions</FormSubmit>
</Col>
</Form>
</Formik>

View File

@ -1,19 +1,20 @@
import React from 'react';
import { Formik, Form } from 'formik';
import React from "react";
import { Formik, Form } from "formik";
import {
ManagedTextInputField as Input,
Col,
Label,
Text
} from '@tlon/indigo-react';
import { Association } from '@urbit/api';
Text,
} from "@tlon/indigo-react";
import { Association } from "@urbit/api";
import { FormError } from '~/views/components/FormError';
import { ColorInput } from '~/views/components/ColorInput';
import { uxToHex } from '~/logic/lib/util';
import GlobalApi from '~/logic/api/global';
import { FormSubmit } from '~/views/components/FormSubmit';
import { FormError } from "~/views/components/FormError";
import { ColorInput } from "~/views/components/ColorInput";
import { uxToHex } from "~/logic/lib/util";
import GlobalApi from "~/logic/api/global";
import { FormSubmit } from "~/views/components/FormSubmit";
import { FormGroupChild } from "~/views/components/FormGroup";
interface FormSchema {
title: string;
@ -30,9 +31,9 @@ export function ChannelDetails(props: ChannelDetailsProps) {
const { association, api } = props;
const { metadata } = association;
const initialValues: FormSchema = {
title: metadata?.title || '',
description: metadata?.description || '',
color: metadata?.color || '0x0'
title: metadata?.title || "",
description: metadata?.description || "",
color: metadata?.color || "0x0",
};
const onSubmit = async (values: FormSchema, actions) => {
@ -44,8 +45,9 @@ export function ChannelDetails(props: ChannelDetailsProps) {
return (
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form style={{ display: 'contents' }}>
<Col mb="4" flexShrink={0} gapY="4">
<Form style={{ display: "contents" }}>
<FormGroupChild id="details" />
<Col mx="4" mb="4" flexShrink={0} gapY="4">
<Col mb={3}>
<Text id="details" fontSize="2" fontWeight="bold">
Channel Details
@ -69,9 +71,6 @@ export function ChannelDetails(props: ChannelDetailsProps) {
label="Color"
caption="Change the color of this channel"
/>
<FormSubmit>
Update Details
</FormSubmit>
<FormError message="Failed to update settings" />
</Col>
</Form>

View File

@ -28,7 +28,7 @@ export function ChannelNotifications(props: ChannelNotificationsProps) {
const anchorRef = useRef<HTMLElement | null>(null);
return (
<Col mb="6" gapY="4" flexShrink={0}>
<Col mx="4" mb="6" gapY="4" flexShrink={0}>
<Text ref={anchorRef} id="notifications" fontSize="2" fontWeight="bold">
Channel Notifications
</Text>

View File

@ -13,7 +13,7 @@ export function ChannelPopoverRoutesSidebar(props: {
return (
<Col
display={['none', 'flex-column']}
display={['none', 'flex']}
minWidth="200px"
borderRight="1"
borderRightColor="washedGray"
@ -27,13 +27,13 @@ export function ChannelPopoverRoutesSidebar(props: {
Preferences
</Text>
<SidebarItem
icon="Inbox"
icon='Notifications'
text="Notifications"
to={relativePath('/settings#notifications')}
/>
{!isOwner && (
<SidebarItem
icon="SignOut"
icon="LogOut"
text="Unsubscribe"
color="red"
to={relativePath('/settings#unsubscribe')}
@ -45,7 +45,7 @@ export function ChannelPopoverRoutesSidebar(props: {
Administration
</Text>
<SidebarItem
icon="Boot"
icon="BootNode"
text="Channel Details"
to={relativePath('/settings#details')}
/>
@ -56,14 +56,14 @@ export function ChannelPopoverRoutesSidebar(props: {
/>
{ isOwner ? (
<SidebarItem
icon="TrashCan"
icon="X"
text="Archive Channel"
to={relativePath('/settings#archive')}
color="red"
/>
) : (
<SidebarItem
icon="TrashCan"
icon="X"
text="Archive Channel"
to={relativePath('/settings#remove')}
color="red"

View File

@ -19,6 +19,7 @@ import { useHistory, Link } from 'react-router-dom';
import { ChannelNotifications } from './Notifications';
import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton';
import { isChannelAdmin, isHost } from '~/logic/lib/group';
import {FormGroup} from '~/views/components/FormGroup';
interface ChannelPopoverRoutesProps {
baseUrl: string;
@ -83,10 +84,10 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
isOwner={isOwner}
baseUrl={props.baseUrl}
/>
<Col height="100%" overflowY="auto" p="5" flexGrow={1}>
<FormGroup onReset={onDismiss} height="100%" overflowY="auto" pt="5" flexGrow={1}>
<ChannelNotifications {...props} />
{!isOwner && (
<Col mb="6" flexShrink={0}>
<Col mx="4" mb="6" flexShrink={0}>
<Text id="unsubscribe" fontSize="2" fontWeight="bold">
Unsubscribe from Channel
</Text>
@ -107,7 +108,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
<ChannelDetails {...props} />
<GraphPermissions {...props} />
{ isOwner ? (
<Col mt="5" mb="6" flexShrink={0}>
<Col mx="4" mt="5" mb="6" flexShrink={0}>
<Text id="archive" fontSize="2" fontWeight="bold">
Archive channel
</Text>
@ -124,7 +125,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
</Col>
) : (
<Col mt="5" mb="6" flexShrink={0}>
<Col mx="4" my="6" flexShrink={0}>
<Text id="remove" fontSize="2" fontWeight="bold">
Remove channel from group
</Text>
@ -143,7 +144,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
)}
</>
)}
</Col>
</FormGroup>
</Row>
</ModalOverlay>
);

View File

@ -36,7 +36,7 @@ return;
? 'Permanently delete this group. (All current members will no longer see this group.)'
: 'You can rejoin if it is an open group, or if you are reinvited';
const icon = props.owner ? 'X' : 'SignOut';
const icon = props.owner ? 'X' : 'LogOut';
const { modal, showModal } = useModal({ modal:
(dismiss: () => void) => {
const onCancel = (e) => {

View File

@ -29,9 +29,9 @@ export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): R
width="40px"
height="40px"
metadata={metadata}
flexShrink="0"
flexShrink={0}
/>
<Col justifyContent="space-between" flexGrow="1" overflow="hidden">
<Col justifyContent="space-between" flexGrow={1} overflow="hidden">
<Text
fontSize="1"
textOverflow="ellipsis"

View File

@ -180,7 +180,7 @@ export function GroupSwitcher(props: {
>
<Row flexGrow={1} alignItems="center" width='100%' minWidth='0' flexShrink={0}>
{ metadata && <MetadataIcon flexShrink={0} mr="2" metadata={metadata} height="24px" width="24px" /> }
<Text flexShrink={1} lineHeight="1.1" fontSize='2' fontWeight="600" overflow='hidden' display='inline-block' flexShrink='1' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{title}</Text>
<Text flexShrink={1} lineHeight="1.1" fontSize='2' fontWeight="600" overflow='hidden' display='inline-block' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{title}</Text>
</Row>
</Dropdown>
<Row pr='3' verticalAlign="middle">

View File

@ -69,7 +69,7 @@ export function GroupifyForm(props: GroupifyFormProps) {
onSubmit={onGroupify}
>
<Form>
<Col flexShrink="0" gapY="4" maxWidth="512px">
<Col flexShrink={0} gapY="4" maxWidth="512px">
<Box>
<Text fontWeight="500">Groupify this channel</Text>
</Box>

View File

@ -147,7 +147,7 @@ export function PostInput(props) {
<LoadingSpinner />
) : (
<Icon
icon='Links'
icon='Attachment'
width='16'
height='16'
onClick={uploadImage}

View File

@ -79,6 +79,7 @@ export default function PostReplies(props) {
baseUrl={baseUrl}
history={history}
isParent={true}
parentPost={parentNode?.post}
vip={vip}
group={group}
/>

View File

@ -177,7 +177,7 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
<Text gray fontSize="1">
Channels
</Text>
<Box width="100%" flexShrink="0">
<Box width="100%" flexShrink={0}>
{Object.values(preview.channels).map(({ metadata }: any) => (
<Row width="100%">
<Icon

View File

@ -11,7 +11,7 @@ import * as Yup from 'yup';
import GlobalApi from '~/logic/api/global';
import { AsyncButton } from '~/views/components/AsyncButton';
import { FormError } from '~/views/components/FormError';
import { RouteComponentProps } from 'react-router-dom';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { stringToSymbol, parentPath, deSig } from '~/logic/lib/util';
import { resourceFromPath } from '~/logic/lib/group';
import { Associations } from '@urbit/api/metadata';
@ -46,8 +46,9 @@ interface NewChannelProps {
workspace: Workspace;
}
export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactElement {
const { history, api, group, workspace } = props;
export function NewChannel(props: NewChannelProps): ReactElement {
const history = useHistory();
const { api, group, workspace } = props;
const groups = useGroupState(state => state.groups);
const waiter = useWaitForProps({ groups }, 5000);
@ -152,7 +153,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactE
name="moduleType"
/>
<IconRadio
icon="Publish"
icon="Notebook"
label="Notebook"
id="publish"
name="moduleType"

View File

@ -181,9 +181,9 @@ export function Participants(props: {
mb={2}
px={2}
zIndex={1}
flexShrink="0"
flexShrink={0}
>
<Row mr="4" flexShrink="0">
<Row mr="4" flexShrink={0}>
<Tab
selected={filter}
setSelected={setFilter}
@ -206,9 +206,9 @@ export function Participants(props: {
/>
</Row>
</Row>
<Col flexShrink="0" width="100%" height="fit-content">
<Col flexShrink={0} width="100%" height="fit-content">
<Row alignItems="center" bg="washedGray" borderRadius="1" px="2" my="2">
<Icon color="gray" icon="MagnifyingGlass" />
<Icon color="gray" icon="Search" />
<Input
maxWidth="256px"
color="gray"

View File

@ -76,7 +76,7 @@ export function PopoverRoutes(
<Col gapY="2">
<Text my="1" mx="3" gray>Group</Text>
<SidebarItem
icon="Inbox"
icon='Notifications'
to={relativeUrl('/settings#notifications')}
text="Notifications"
/>
@ -98,7 +98,7 @@ export function PopoverRoutes(
text="Group Details"
/>
<SidebarItem
icon="Spaces"
icon="Dashboard"
to={relativeUrl('/settings#channels')}
text="Channel Management"
/>

View File

@ -77,7 +77,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
fontSize='1'
mr='12px'
my='1'
flexShrink='0'
flexShrink={0}
display={['block','none']}
>
<Link to={`/~landscape${workspace}`}>
@ -98,7 +98,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
maxWidth={association?.metadata?.description ? ['100%', '50%'] : 'none'}
mr='2'
ml='1'
flexShrink={['1', '0']}
flexShrink={[1, 0]}
>
{title}
</Text>
@ -112,7 +112,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
mb='0'
minWidth='0'
maxWidth='50%'
flexShrink='1'
flexShrink={1}
disableRemoteContent
>
{workspace === '/messages'
@ -145,7 +145,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
return (
<Col width='100%' height='100%' overflow='hidden'>
<Box
flexShrink='0'
flexShrink={0}
height='48px'
py='2'
px='2'
@ -159,7 +159,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
display='flex'
alignItems='baseline'
width={`calc(100% - ${actionsWidth}px - 16px)`}
flexShrink='0'
flexShrink={0}
>
<BackLink />
<Title />
@ -169,7 +169,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
ml={3}
display='flex'
alignItems='center'
flexShrink='0'
flexShrink={0}
ref={actionsRef}
>
{canWrite && <WriterControls />}

View File

@ -86,7 +86,11 @@ export function SidebarItem(props: {
let color = 'lightGray';
if (isSynced) {
if (hasUnread || hasNotification) {
color = 'black';
} else {
color = 'gray';
}
}
const fontWeight = (hasUnread || hasNotification) ? '500' : 'normal';
@ -132,7 +136,7 @@ export function SidebarItem(props: {
{DM ? img : (
<Icon
display="block"
color={isSynced ? 'black' : 'gray'}
color={isSynced ? 'black' : 'lightGray'}
icon={getModuleIcon(mod) as any}
/>
)

View File

@ -1,6 +1,6 @@
import React, { ReactElement, useCallback } from 'react';
import { FormikHelpers } from 'formik';
import { Link } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import {
Row,
@ -34,6 +34,7 @@ export function SidebarListHeader(props: {
workspace: Workspace;
handleSubmit: (c: SidebarListConfig) => void;
}): ReactElement {
const history = useHistory();
const onSubmit = useCallback(
(values: SidebarListConfig, actions: FormikHelpers<SidebarListConfig>) => {
props.handleSubmit(values);
@ -65,7 +66,7 @@ export function SidebarListHeader(props: {
<Box>
{( !!feedPath) ? (
<Row
flexShrink="0"
flexShrink={0}
alignItems="center"
justifyContent="space-between"
py={2}
@ -74,18 +75,18 @@ export function SidebarListHeader(props: {
borderBottom={1}
borderColor="lightGray"
backgroundColor={['transparent',
props.history.location.pathname.includes(`/~landscape${groupPath}/feed`)
history.location.pathname.includes(`/~landscape${groupPath}/feed`)
? (
'washedGray'
) : (
'transparent'
)]}
cursor={['pointer', (
props.history.location.pathname === `/~landscape${groupPath}/feed`
cursor={(
history.location.pathname === `/~landscape${groupPath}/feed`
? 'default' : 'pointer'
)]}
)}
onClick={() => {
props.history.push(`/~landscape${groupPath}/feed`);
history.push(`/~landscape${groupPath}/feed`);
}}
>
<Text>
@ -98,14 +99,14 @@ export function SidebarListHeader(props: {
) : null
}
<Row
flexShrink="0"
flexShrink={0}
alignItems="center"
justifyContent="space-between"
py={2}
px={3}
height='48px'
>
<Box flexShrink='0'>
<Box flexShrink={0}>
<Text>
{props.initialValues.hideUnjoined ? `Joined ${noun}` : `All ${noun}`}
</Text>
@ -131,7 +132,6 @@ export function SidebarListHeader(props: {
>
<NewChannel
api={props.api}
history={props.history}
workspace={props.workspace}
/>
</Col>
@ -152,7 +152,7 @@ export function SidebarListHeader(props: {
)
}
<Dropdown
flexShrink='0'
flexShrink={0}
width="auto"
alignY="top"
alignX={['right', 'left']}

View File

@ -1,227 +1,62 @@
import { BigInteger } from "big-integer";
import { immerable } from 'immer';
import bigInt, { BigInteger } from "big-integer";
interface NonemptyNode<V> {
n: [BigInteger, V];
l: MapNode<V>;
r: MapNode<V>;
function sortBigInt(a: BigInteger, b: BigInteger) {
if (a.lt(b)) {
return 1;
} else if (a.eq(b)) {
return 0;
} else {
return -1;
}
}
type MapNode<V> = NonemptyNode<V> | null;
/**
* An implementation of ordered maps for JS
* Plagiarised wholesale from sys/zuse
*/
export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
private root: MapNode<V> = null;
private root: Record<string, V> = {}
private cachedIter: [BigInteger, V][] | null = null;
[immerable] = true;
size: number = 0;
constructor(initial: [BigInteger, V][] = []) {
initial.forEach(([key, val]) => {
constructor(items: [BigInteger, V][] = []) {
items.forEach(([key, val]) => {
this.set(key, val);
});
this.generateCachedIter();
}
/**
* Retrieve an value for a key
*/
get(key: BigInteger): V | null {
const inner = (node: MapNode<V>): V | null => {
if (!node) {
return null;
}
const [k, v] = node.n;
if (key.eq(k)) {
return v;
}
if (key.gt(k)) {
return inner(node.l);
} else {
return inner(node.r);
}
};
return inner(this.root);
get size() {
return this.cachedIter?.length ?? Object.keys(this.root).length;
}
/**
* Put an item by a key
*/
set(key: BigInteger, value: V): void {
const inner = (node: MapNode<V>): MapNode<V> => {
if (!node) {
return {
n: [key, value],
l: null,
r: null,
};
}
const [k] = node.n;
if (key.eq(k)) {
this.size--;
return {
...node,
n: [k, value],
};
}
if (key.gt(k)) {
const l = inner(node.l);
if (!l) {
throw new Error("invariant violation");
}
return {
...node,
l,
};
}
const r = inner(node.r);
if (!r) {
throw new Error("invariant violation");
}
return { ...node, r };
};
this.size++;
this.root = inner(this.root);
get(key: BigInteger) {
return this.root[key.toString()] ?? null;
}
set(key: BigInteger, value: V) {
this.root[key.toString()] = value;
this.cachedIter = null;
}
/**
* Remove all entries
*/
clear() {
this.root = null;
this.cachedIter = null;
this.root = {}
}
/**
* Predicate testing if map contains key
*/
has(key: BigInteger): boolean {
const inner = (node: MapNode<V>): boolean => {
if (!node) {
return false;
}
const [k] = node.n;
if (k.eq(key)) {
return true;
}
if (key.gt(k)) {
return inner(node.l);
}
return inner(node.r);
};
return inner(this.root);
has(key: BigInteger) {
return key.toString() in this.root;
}
/**
* Remove value associated with key, returning whether that key
* existed in the first place
*/
delete(key: BigInteger) {
const inner = (node: MapNode<V>): [boolean, MapNode<V>] => {
if (!node) {
return [false, null];
}
const [k] = node.n;
if (k.eq(key)) {
return [true, this.nip(node)];
}
if (key.gt(k)) {
const [bool, l] = inner(node.l);
return [
bool,
{
...node,
l,
},
];
}
const [bool, r] = inner(node.r);
return [
bool,
{
...node,
r,
},
];
};
const [ret, newRoot] = inner(this.root);
if(ret) {
this.size--;
const had = this.has(key);
if(had) {
delete this.root[key.toString()];
this.cachedIter = null;
}
this.root = newRoot;
return ret;
}
private nip(nod: NonemptyNode<V>): MapNode<V> {
const inner = (node: NonemptyNode<V>): MapNode<V> => {
if (!node.l) {
return node.r;
}
if (!node.r) {
return node.l;
}
return {
...node.l,
r: inner(node.r),
};
};
return inner(nod);
}
peekLargest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>): [BigInteger, V] | undefined => {
if(!node) {
return undefined;
}
if(node.l) {
return inner(node.l);
}
return node.n;
}
return inner(this.root);
}
peekSmallest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>): [BigInteger, V] | undefined => {
if(!node) {
return undefined;
}
if(node.r) {
return inner(node.r);
}
return node.n;
}
return inner(this.root);
}
keys(): BigInteger[] {
const list = Array.from(this);
return list.map(([key]) => key);
}
forEach(f: (value: V, key: BigInteger) => void) {
const list = Array.from(this);
return list.forEach(([k,v]) => f(v,k));
return had;
}
[Symbol.iterator](): IterableIterator<[BigInteger, V]> {
let result: [BigInteger, V][] = [];
const inner = (node: MapNode<V>) => {
if (!node) {
return;
}
inner(node.l);
result.push(node.n);
inner(node.r);
};
inner(this.root);
let idx = 0;
const result = this.generateCachedIter();
return {
[Symbol.iterator]: this[Symbol.iterator],
next: (): IteratorResult<[BigInteger, V]> => {
@ -232,4 +67,31 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
},
};
}
peekLargest() {
const sorted = Array.from(this);
return sorted[0] as [BigInteger, V] | null;
}
peekSmallest() {
const sorted = Array.from(this);
return sorted[sorted.length - 1] as [BigInteger, V] | null;
}
keys() {
return Object.keys(this.root).map(k => bigInt(k)).sort(sortBigInt)
}
private generateCachedIter() {
if(this.cachedIter) {
return this.cachedIter;
}
const result = Object.keys(this.root).map(key => {
const num = bigInt(key);
return [num, this.root[key]] as [BigInteger, V];
}).sort(([a], [b]) => sortBigInt(a,b));
this.cachedIter = result;
return result;
}
}