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 version https://git-lfs.github.com/spec/v1
oid sha256:1d56b7351a347a65c06999955114f196523a86c853390d5d1822a90a606619d6 oid sha256:f6b5e33e573818120051651c1182163527edbbe0dff0eb6591e12a55cfccb273
size 10357558 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 let
startUrbit = writeScriptBin "start-urbit" '' startUrbit = writeScriptBin "start-urbit" ''
#!${bashInteractive}/bin/bash #!${bashInteractive}/bin/bash
@ -59,11 +59,41 @@ let
exec urbit $ttyflag -p $amesPort $dirname 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 { in dockerTools.buildImage {
name = "urbit"; name = "urbit";
tag = "v${urbit.version}"; tag = "v${urbit.version}";
contents = [ bashInteractive urbit startUrbit coreutils ]; contents = [ bashInteractive urbit curl startUrbit getUrbitCode resetUrbitCode coreutils ];
runAsRoot = '' runAsRoot = ''
#!${bashInteractive} #!${bashInteractive}
mkdir -p /urbit mkdir -p /urbit

View File

@ -126,6 +126,14 @@
!=(contact(last-updated *@da) u.old(last-updated *@da)) !=(contact(last-updated *@da) u.old(last-updated *@da))
== ==
[~ state] [~ 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)) :- (send-diff [%add ship contact] =(ship our.bowl))
state(rolodex (~(put by rolodex) ship contact)) state(rolodex (~(put by rolodex) ship contact))
:: ::
@ -149,6 +157,14 @@
=/ contact (edit-contact old edit-field) =/ contact (edit-contact old edit-field)
?: =(old contact) ?: =(old contact)
[~ state] [~ 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 =. last-updated.contact timestamp
:- (send-diff [%edit ship edit-field timestamp] =(ship our.bowl)) :- (send-diff [%edit ship edit-field timestamp] =(ship our.bowl))
state(rolodex (~(put by rolodex) ship contact)) state(rolodex (~(put by rolodex) ship contact))

View File

@ -5,7 +5,7 @@
/- glob /- glob
/+ default-agent, verb, dbug /+ 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))] +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states +$ all-states
$% state-0 $% state-0

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div> <div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script> <script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.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> </body>
</html> </html>

View File

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

View File

@ -249,6 +249,7 @@
font-family: "Source Code Pro"; font-family: "Source Code Pro";
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff"); src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
font-weight: 400; font-weight: 400;
font-display: swap;
} }
:root { :root {
--red05: rgba(255,65,54,0.05); --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 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 ## 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. 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, 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, 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/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5", "@reach/tabs": "^0.10.5",
"@tlon/indigo-dark": "^1.0.6", "@tlon/indigo-dark": "^1.0.6",
"@tlon/indigo-light": "^1.0.6", "@tlon/indigo-light": "^1.0.7",
"@tlon/indigo-react": "^1.2.19", "@tlon/indigo-react": "^1.2.21",
"@tlon/sigil-js": "^1.4.3", "@tlon/sigil-js": "^1.4.3",
"@urbit/api": "file:../npm/api", "@urbit/api": "file:../npm/api",
"any-ascii": "^0.1.7", "any-ascii": "^0.1.7",
@ -88,7 +88,7 @@
"react-hot-loader": "^4.13.0", "react-hot-loader": "^4.13.0",
"sass": "^1.32.5", "sass": "^1.32.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"typescript": "^3.9.7", "typescript": "^4.2.4",
"webpack": "^4.46.0", "webpack": "^4.46.0",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.2" "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 { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
import { BigInteger } from 'big-integer'; import { BigInteger } from 'big-integer';
import { getParentIndex } from '../lib/notification'; import { getParentIndex } from '../lib/notification';
import useHarkState from '../state/hark';
function getHarkSize() {
return useHarkState.getState().notifications.size ?? 0;
}
export class HarkApi extends BaseApi<StoreState> { export class HarkApi extends BaseApi<StoreState> {
private harkAction(action: any): Promise<any> { private harkAction(action: any): Promise<any> {
@ -172,10 +177,10 @@ export class HarkApi extends BaseApi<StoreState> {
} }
async getMore(): Promise<boolean> { async getMore(): Promise<boolean> {
const offset = this.store.state['notifications']?.size || 0; const offset = getHarkSize();
const count = 3; const count = 3;
await this.getSubset(offset, count, false); 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) { 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 bigInt, { BigInteger } from 'big-integer';
import f from 'lodash/fp'; import f from 'lodash/fp';
import { Unreads } from '@urbit/api'; import { Unreads, NotificationGraphConfig } from '@urbit/api';
export function getLastSeen( export function getLastSeen(
unreads: Unreads, unreads: Unreads,
@ -34,3 +34,13 @@ export function getNotificationCount(
.map(index => unread[index]?.notifications?.length || 0) .map(index => unread[index]?.notifications?.length || 0)
.reduce(f.add, 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_IOS = ua.includes('iPhone');
export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome'); 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 { buntPost } from '~/logic/lib/post';
import { unixToDa } from '~/logic/lib/util'; import { unixToDa } from '~/logic/lib/util';
import { BigIntOrderedMap } from './BigIntOrderedMap'; import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
export function newPost( export function newPost(
title: string, title: string,
body: string body: string
): [BigInteger, NodeMap] { ): [BigInteger, any] {
const now = Date.now(); const now = Date.now();
const nowDa = unixToDa(now); const nowDa = unixToDa(now);
const root: Post = { const root: Post = {
@ -73,12 +73,15 @@ export function editPost(rev: number, noteId: BigInteger, title: string, body: s
} }
export function getLatestRevision(node: GraphNode): [number, string, string, Post] { 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]; const empty = [1, '', '', buntPost()] as [number, string, string, Post];
if(!revs) { if(!revs) {
return empty; return empty;
} }
const [revNum, rev] = [...revs.children][0]; let revNum, rev;
if (revs?.children !== null) {
[revNum, rev] = [...revs.children][0];
}
if (!rev) { if (!rev) {
return empty; return empty;
} }
@ -88,10 +91,14 @@ export function getLatestRevision(node: GraphNode): [number, string, string, Pos
export function getLatestCommentRevision(node: GraphNode): [number, Post] { export function getLatestCommentRevision(node: GraphNode): [number, Post] {
const empty = [1, buntPost()] as [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; return empty;
} }
const [revNum, rev] = [...node.children][0]; let revNum, rev;
if (node?.children !== null) {
[revNum, rev] = [...node.children][0];
}
if (!rev) { if (!rev) {
return empty; return empty;
} }
@ -99,7 +106,7 @@ export function getLatestCommentRevision(node: GraphNode): [number, Post] {
} }
export function getComments(node: GraphNode): GraphNode { export function getComments(node: GraphNode): GraphNode {
const comments = node.children.get(bigInt(2)); const comments = node.children?.get(bigInt(2));
if(!comments) { if(!comments) {
return { post: buntPost(), children: new BigIntOrderedMap() }; 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 { AlignX, AlignY } from '~/logic/lib/relativePosition';
import { Direction } from '~/views/components/Triangle'; import { Direction } from '~/views/components/Triangle';
@ -22,7 +23,7 @@ interface StepDetail {
alignY: AlignY | AlignY[]; alignY: AlignY | AlignY[];
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
arrow: Direction; arrow?: Direction;
} }
export function hasTutorialGroup(props: { associations: Associations }) { export function hasTutorialGroup(props: { associations: Associations }) {

View File

@ -15,7 +15,8 @@ function retrieve<T>(key: string, initial: T): T {
interface SetStateFunc<T> { interface SetStateFunc<T> {
(t: T): 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) { export function useLocalStorageState<T>(key: string, initial: T) {
const [state, _setState] = useState(() => retrieve(key, initial)); const [state, _setState] = useState(() => retrieve(key, initial));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ export const getModuleIcon = (mod: string) => {
} }
if (mod === 'post') { if (mod === 'post') {
return 'Spaces'; return 'Dashboard';
} }
return _.capitalize(mod); return _.capitalize(mod);
@ -192,7 +192,10 @@ export function uxToHex(ux: string) {
export const hexToUx = (hex) => { export const hexToUx = (hex) => {
const ux = f.flow( const ux = f.flow(
f.chunk(4), 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('.') f.join('.')
)(hex.split('')); )(hex.split(''));
return `0x${ux}`; return `0x${ux}`;

View File

@ -7,6 +7,7 @@ import React, {
useEffect, useEffect,
} from "react"; } from "react";
import usePreviousValue from "./usePreviousValue"; import usePreviousValue from "./usePreviousValue";
import {Primitive} from "~/types";
export interface VirtualContextProps { export interface VirtualContextProps {
save: () => void; save: () => void;
@ -49,7 +50,7 @@ export function useVirtualResizeState(s: boolean) {
return [state, setState] as const; return [state, setState] as const;
} }
export function useVirtualResizeProp<T>(prop: T) { export function useVirtualResizeProp(prop: Primitive) {
const { save, restore } = useVirtual(); const { save, restore } = useVirtual();
const oldProp = usePreviousValue(prop) const oldProp = usePreviousValue(prop)
@ -58,7 +59,7 @@ export function useVirtualResizeProp<T>(prop: T) {
} }
useLayoutEffect(() => { useLayoutEffect(() => {
restore(); requestAnimationFrame(restore);
}, [prop]); }, [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( export function getTitleFromWorkspace(
associations: Associations, 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'; import { Cage } from '~/types/cage';
type LocalState = Pick<StoreState, 'connection'>; type LocalState = Pick<StoreState, 'connection'>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import _ from 'lodash';
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api"; import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
import { BaseState, createState } from "./base"; 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])); 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', { const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} }, associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
// preview: async (group): Promise<MetadataUpdatePreview> => { // preview: async (group): Promise<MetadataUpdatePreview> => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,7 +44,7 @@ export function PostForm(props: PostFormProps) {
validateOnBlur validateOnBlur
> >
<Form style={{ display: 'contents' }}> <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" /> <Input maxWidth='40rem' width='100%' flexShrink={[0, 1]} placeholder="Post Title" id="title" />
<Row flexDirection={['column', 'row']} mb={[4,0]}> <Row flexDirection={['column', 'row']} mb={[4,0]}>
<AsyncButton <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 useHarkState from "~/logic/state/hark";
import _ from "lodash"; import _ from "lodash";
import {AsyncButton} from "~/views/components/AsyncButton"; import {AsyncButton} from "~/views/components/AsyncButton";
import {GroupChannelPicker} from "./GroupChannelPicker";
import {isWatching} from "~/logic/lib/hark";
interface FormSchema { interface FormSchema {
mentions: boolean; mentions: boolean;
dnd: boolean; dnd: boolean;
watchOnSelf: boolean; watchOnSelf: boolean;
graph: {
[rid: string]: boolean;
};
groups: {
[rid: string]: boolean;
}
} }
export function NotificationPreferences(props: { export function NotificationPreferences(props: {
@ -23,6 +31,7 @@ export function NotificationPreferences(props: {
const { api } = props; const { api } = props;
const dnd = useHarkState(state => state.doNotDisturb); const dnd = useHarkState(state => state.doNotDisturb);
const graphConfig = useHarkState(state => state.notificationsGraphConfig); const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const groupConfig = useHarkState(s => s.notificationsGroupConfig);
const initialValues = { const initialValues = {
mentions: graphConfig.mentions, mentions: graphConfig.mentions,
dnd: dnd, dnd: dnd,
@ -41,6 +50,16 @@ export function NotificationPreferences(props: {
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) { if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
promises.push(api.hark.setDoNotDisturb(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); await Promise.all(promises);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
@ -81,6 +100,15 @@ export function NotificationPreferences(props: {
id="mentions" id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined" 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"> <AsyncButton primary width="fit-content">
Save Save
</AsyncButton> </AsyncButton>

View File

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

View File

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

View File

@ -25,6 +25,7 @@ interface DropdownProps {
offsetY?: number; offsetY?: number;
width?: string; width?: string;
dropWidth?: string; dropWidth?: string;
flexShrink?: number;
} }
const ClickBox = styled(Box)` const ClickBox = styled(Box)`
@ -39,7 +40,7 @@ const DropdownOptions = styled(Box)`
`; `;
export function Dropdown(props: DropdownProps): ReactElement { 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 dropdownRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null); const anchorRef = useRef<HTMLElement>(null);
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -47,6 +48,9 @@ export function Dropdown(props: DropdownProps): ReactElement {
const [coords, setCoords] = useState({}); const [coords, setCoords] = useState({});
const updatePos = useCallback(() => { const updatePos = useCallback(() => {
if(!anchorRef.current) {
return;
}
const newCoords = getRelativePosition(anchorRef.current, props.alignX, props.alignY, offsetX, offsetY); const newCoords = getRelativePosition(anchorRef.current, props.alignX, props.alignY, offsetX, offsetY);
if(newCoords) { if(newCoords) {
setCoords(newCoords); setCoords(newCoords);
@ -86,7 +90,7 @@ export function Dropdown(props: DropdownProps): ReactElement {
}, []); }, []);
return ( 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}> <ClickBox width='100%' ref={anchorRef} onClick={onOpen}>
{children} {children}
</ClickBox> </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 <Box
border="2px solid" border="2px solid"
borderRadius={2} borderRadius={3}
borderColor={color} borderColor={color}
position="absolute" position="absolute"
left="0px" left="0px"

View File

@ -83,7 +83,7 @@ const StatusBar = (props) => {
onClick={() => history.push('/')} onClick={() => history.push('/')}
{...props} {...props}
> >
<Icon icon='Spaces' color='black' /> <Icon icon='Dashboard' color='black' />
</Button> </Button>
<StatusBarItem float={floatLeap} mr={2} onClick={() => toggleOmnibox()}> <StatusBarItem float={floatLeap} mr={2} onClick={() => toggleOmnibox()}>
{!doNotDisturb && (notificationsCount > 0 || invites.length > 0) && ( {!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 * A map of child refs, used to calculate scroll position
*/ */
private childRefs = new BigIntOrderedMap<HTMLElement>(); 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 * 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 scrollRef: HTMLElement | null = null;
private cleanupRefInterval: NodeJS.Timeout | null = null;
private initScroll: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) { constructor(props: VirtualScrollerProps<T>) {
super(props); super(props);
this.state = { 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.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 400) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this); this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this); this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -164,8 +173,27 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.resetScroll(); this.resetScroll();
this.loadTop(); this.loadTop();
this.loadBottom(); 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 // manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => { updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) { if(!this.window || !this.scrollRef) {
@ -199,6 +227,12 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler); window.removeEventListener('keydown', this.invertedKeyHandler);
if(this.cleanupRefInterval) {
clearInterval(this.cleanupRefInterval);
}
if(this.initScroll) {
clearTimeout(this.initScroll);
}
} }
startOffset() { startOffset() {
@ -237,9 +271,6 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}, () => { }, () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.restore(); 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 // bail if we're going to adjust scroll anyway
return; return;
} }
if(this.initScroll) {
clearTimeout(this.initScroll);
this.initScroll = null;
}
if(this.saveDepth > 0) { if(this.saveDepth > 0) {
log('bail', 'deep scroll queue'); log('bail', 'deep scroll queue');
return; return;
@ -394,8 +429,15 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('bail', 'Deep restore'); log('bail', 'Deep restore');
return; 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; const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop); this.window.scrollTo(0, newScrollTop);
@ -435,12 +477,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!this.window || this.savedIndex) { if(!this.window || this.savedIndex) {
return; return;
} }
this.saveDepth++; if(this.saveDepth !== 0) {
if(this.saveDepth !== 1) {
console.log('bail', 'deep save'); console.log('bail', 'deep save');
return; return;
} }
this.saveDepth++;
let bottomIndex: BigInteger | null = null; let bottomIndex: BigInteger | null = null;
const { scrollTop, scrollHeight } = this.window; const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop; const topSpacing = scrollHeight - scrollTop;
@ -472,10 +515,9 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
setRef = (element: HTMLElement | null, index: BigInteger) => { setRef = (element: HTMLElement | null, index: BigInteger) => {
if(element) { if(element) {
this.childRefs.set(index, element); this.childRefs.set(index, element);
this.orphans.delete(index.toString());
} else { } else {
setTimeout(() => { this.orphans.add(index.toString());
this.childRefs.delete(index);
});
} }
} }

View File

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

View File

@ -2,6 +2,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap;
src: url("/~landscape/fonts/inter-regular.woff2") format("woff2"), src: url("/~landscape/fonts/inter-regular.woff2") format("woff2"),
url("https://media.urbit.org/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-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap;
src: url("https://media.urbit.org/fonts/Inter-Medium.woff2") format("woff2"); src: url("https://media.urbit.org/fonts/Inter-Medium.woff2") format("woff2");
} }
@ -17,6 +19,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap;
src: url("https://media.urbit.org/fonts/Inter-SemiBold.woff2") format("woff2"); src: url("https://media.urbit.org/fonts/Inter-SemiBold.woff2") format("woff2");
} }
@ -24,6 +27,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
font-display: swap;
src: url("/~landscape/fonts/inter-italic.woff2") format("woff2"), src: url("/~landscape/fonts/inter-italic.woff2") format("woff2"),
url("https://media.urbit.org/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-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap;
src: url("/~landscape/fonts/inter-bold.woff2") format("woff2"), src: url("/~landscape/fonts/inter-bold.woff2") format("woff2"),
url("https://media.urbit.org/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-family: 'Inter';
font-style: italic; font-style: italic;
font-weight: 700; font-weight: 700;
font-display: swap;
src: url("/~landscape/fonts/inter-bolditalic.woff2") format("woff2"), src: url("/~landscape/fonts/inter-bolditalic.woff2") format("woff2"),
url("https://media.urbit.org/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"), src: url("/~landscape/fonts/sourcecodepro-extralight.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-extralight.woff"); url("https://storage.googleapis.com/media.urbit.org/fonts/scp-extralight.woff");
font-weight: 200; font-weight: 200;
font-display: swap;
} }
@font-face { @font-face {
@ -55,6 +62,7 @@
src: url("/~landscape/fonts/sourcecodepro-light.woff2"), src: url("/~landscape/fonts/sourcecodepro-light.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-light.woff"); url("https://storage.googleapis.com/media.urbit.org/fonts/scp-light.woff");
font-weight: 300; font-weight: 300;
font-display: swap;
} }
@font-face { @font-face {
@ -62,6 +70,7 @@
src: url("/~landscape/fonts/sourcecodepro-regular.woff2"), src: url("/~landscape/fonts/sourcecodepro-regular.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff"); url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
font-weight: 400; font-weight: 400;
font-display: swap;
} }
@font-face { @font-face {
@ -69,6 +78,7 @@
src: url("(/~landscape/fonts/sourcecodepro-medium.woff2"), src: url("(/~landscape/fonts/sourcecodepro-medium.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-medium.woff"); url("https://storage.googleapis.com/media.urbit.org/fonts/scp-medium.woff");
font-weight: 500; font-weight: 500;
font-display: swap;
} }
@font-face { @font-face {
@ -76,6 +86,7 @@
src: url("/~landscape/fonts/sourcecodepro-semibold.woff2"), src: url("/~landscape/fonts/sourcecodepro-semibold.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-semibold.woff"); url("https://storage.googleapis.com/media.urbit.org/fonts/scp-semibold.woff");
font-weight: 600; font-weight: 600;
font-display: swap;
} }
@font-face { @font-face {
@ -83,5 +94,6 @@
src: url("/~landscape/fonts/sourcecodepro-bold.woff2"), src: url("/~landscape/fonts/sourcecodepro-bold.woff2"),
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-bold.woff"); url("https://storage.googleapis.com/media.urbit.org/fonts/scp-bold.woff");
font-weight: 700; 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 { resourceFromPath } from '~/logic/lib/group';
import { FormSubmit } from '~/views/components/FormSubmit'; import { FormSubmit } from '~/views/components/FormSubmit';
import { ChannelWritePerms } from '../ChannelWritePerms'; import { ChannelWritePerms } from '../ChannelWritePerms';
import {FormGroupChild} from '~/views/components/FormGroup';
function PermissionsSummary(props: { function PermissionsSummary(props: {
writersSize: number; writersSize: number;
@ -158,7 +159,8 @@ export function GraphPermissions(props: GraphPermissionsProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Form style={{ display: 'contents' }}> <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"> <Col gapY="1" mt="0">
<Text id="permissions" fontWeight="bold" fontSize="2"> <Text id="permissions" fontWeight="bold" fontSize="2">
Permissions Permissions
@ -187,7 +189,6 @@ export function GraphPermissions(props: GraphPermissionsProps) {
caption="If enabled, all members of the group can comment on this channel" caption="If enabled, all members of the group can comment on this channel"
/> />
)} )}
<FormSubmit>Update Permissions</FormSubmit>
</Col> </Col>
</Form> </Form>
</Formik> </Formik>

View File

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

View File

@ -28,7 +28,7 @@ export function ChannelNotifications(props: ChannelNotificationsProps) {
const anchorRef = useRef<HTMLElement | null>(null); const anchorRef = useRef<HTMLElement | null>(null);
return ( 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"> <Text ref={anchorRef} id="notifications" fontSize="2" fontWeight="bold">
Channel Notifications Channel Notifications
</Text> </Text>

View File

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

View File

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

View File

@ -36,7 +36,7 @@ return;
? 'Permanently delete this group. (All current members will no longer see this group.)' ? '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'; : '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: const { modal, showModal } = useModal({ modal:
(dismiss: () => void) => { (dismiss: () => void) => {
const onCancel = (e) => { const onCancel = (e) => {

View File

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

View File

@ -180,7 +180,7 @@ export function GroupSwitcher(props: {
> >
<Row flexGrow={1} alignItems="center" width='100%' minWidth='0' flexShrink={0}> <Row flexGrow={1} alignItems="center" width='100%' minWidth='0' flexShrink={0}>
{ metadata && <MetadataIcon flexShrink={0} mr="2" metadata={metadata} height="24px" width="24px" /> } { 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> </Row>
</Dropdown> </Dropdown>
<Row pr='3' verticalAlign="middle"> <Row pr='3' verticalAlign="middle">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,227 +1,62 @@
import { BigInteger } from "big-integer";
import { immerable } from 'immer'; import { immerable } from 'immer';
import bigInt, { BigInteger } from "big-integer";
interface NonemptyNode<V> { function sortBigInt(a: BigInteger, b: BigInteger) {
n: [BigInteger, V]; if (a.lt(b)) {
l: MapNode<V>; return 1;
r: MapNode<V>; } 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]> { 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; [immerable] = true;
size: number = 0;
constructor(initial: [BigInteger, V][] = []) { constructor(items: [BigInteger, V][] = []) {
initial.forEach(([key, val]) => { items.forEach(([key, val]) => {
this.set(key, val); this.set(key, val);
}); });
this.generateCachedIter();
} }
/** get size() {
* Retrieve an value for a key return this.cachedIter?.length ?? Object.keys(this.root).length;
*/
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);
} }
/**
* Put an item by a key
*/
set(key: BigInteger, value: V): void {
const inner = (node: MapNode<V>): MapNode<V> => { get(key: BigInteger) {
if (!node) { return this.root[key.toString()] ?? null;
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 }; set(key: BigInteger, value: V) {
}; this.root[key.toString()] = value;
this.size++; this.cachedIter = null;
this.root = inner(this.root);
} }
/**
* Remove all entries
*/
clear() { clear() {
this.root = null; this.cachedIter = null;
this.root = {}
} }
/** has(key: BigInteger) {
* Predicate testing if map contains key return key.toString() in this.root;
*/
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);
} }
/**
* Remove value associated with key, returning whether that key
* existed in the first place
*/
delete(key: BigInteger) { delete(key: BigInteger) {
const inner = (node: MapNode<V>): [boolean, MapNode<V>] => { const had = this.has(key);
if (!node) { if(had) {
return [false, null]; delete this.root[key.toString()];
this.cachedIter = null;
} }
const [k] = node.n; return had;
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>): 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));
} }
[Symbol.iterator](): IterableIterator<[BigInteger, V]> { [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; let idx = 0;
const result = this.generateCachedIter();
return { return {
[Symbol.iterator]: this[Symbol.iterator], [Symbol.iterator]: this[Symbol.iterator],
next: (): IteratorResult<[BigInteger, V]> => { 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;
}
}