vere: merge urbit-v1.5 into mingw-port

This commit is contained in:
~locpyl-tidnyd 2021-04-28 07:12:00 +00:00
commit 8e8edf5f68
73 changed files with 9386 additions and 1211 deletions

View File

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

View File

@ -1,10 +1,24 @@
{ urbit, libcap, coreutils, bashInteractive, dockerTools, writeScriptBin, amesPort ? 34343 }:
{ urbit, curl, libcap, coreutils, bashInteractive, dockerTools, writeScriptBin, amesPort ? 34343 }:
let
startUrbit = writeScriptBin "start-urbit" ''
#!${bashInteractive}/bin/bash
set -eu
# set defaults
amesPort=${toString amesPort}
# check args
for i in "$@"
do
case $i in
-p=*|--port=*)
amesPort="''${i#*=}"
shift
;;
esac
done
# If the container is not started with the `-i` flag
# then STDIN will be closed and we need to start
# Urbit/vere with the `-t` flag.
@ -23,7 +37,7 @@ let
mv $keyname /tmp
# Boot urbit with the key, exit when done booting
urbit $ttyflag -w $(basename $keyname .key) -k /tmp/$keyname -c $(basename $keyname .key) -p ${toString amesPort} -x
urbit $ttyflag -w $(basename $keyname .key) -k /tmp/$keyname -c $(basename $keyname .key) -p $amesPort -x
# Remove the keyfile for security
rm /tmp/$keyname
@ -34,7 +48,7 @@ let
cometname=''${comets[0]}
rm *.comet
urbit $ttyflag -c $(basename $cometname .comet) -p ${toString amesPort} -x
urbit $ttyflag -c $(basename $cometname .comet) -p $amesPort -x
fi
# Find the first directory and start urbit with the ship therein
@ -42,14 +56,44 @@ let
dirs=( $dirnames )
dirname=''${dirnames[0]}
urbit $ttyflag -p ${toString 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 {
name = "urbit";
tag = "v${urbit.version}";
contents = [ bashInteractive urbit startUrbit coreutils ];
contents = [ bashInteractive urbit curl startUrbit getUrbitCode resetUrbitCode coreutils ];
runAsRoot = ''
#!${bashInteractive}
mkdir -p /urbit

View File

@ -82,6 +82,8 @@ haskell-nix.stackProject {
urbit-king.components.tests.urbit-king-tests.testFlags =
[ "--brass-pill=${brass.lfs}" ];
lmdb.components.library.libs = lib.mkForce [ lmdb ];
};
}];
}

View File

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

View File

@ -5,7 +5,7 @@
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0v6.qafur.17301.j8obh.vbepn.7tq3l
++ hash 0v3.g6u13.haedt.jt4hd.61ek5.6t30q
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.fd3d400454968e081ca9.js"></script>
<script src="/~landscape/js/bundle/index.59e682153138f604d358.js"></script>
</body>
</html>

View File

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

View File

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

View File

@ -25,9 +25,55 @@ The first two options result in Urbit attempting to boot either the ship named b
In consequence, it is safe to remove the container and start a new container which mounts the same volume, e.g. to upgrade the version of the urbit binary by running a later container version. It is also possible to stop the container and then move the pier away e.g. to a location where you will run it directly with the Urbit binary.
### Ports
The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port `80` is used for Urbit's HTTP interface for both [Landscape](https://urbit.org/docs/glossary/landscape/) and for [API calls](https://urbit.org/using/integrating-api/) to the ship. Port `34343` is used by [Ames](https://urbit.org/docs/glossary/ames/) for ship-to-ship communication.
The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port `80` is used for Urbit's HTTP interface for both [Landscape](https://urbit.org/docs/glossary/landscape/) and for [API calls](https://urbit.org/using/integrating-api/) to the ship. Port `34343` is set by default to be used by [Ames](https://urbit.org/docs/glossary/ames/) for ship-to-ship communication.
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.
For best performance, you must map the Ames UDP port to the *same* port on the host. If you map to a different port Ames will not be able to make direct connections and your network performance may suffer somewhat. Note that using the same port is required for direct connections but is not by itself sufficient for them. If you are behind a NAT router or the host is not on a public IP address or you are firewalled, you may not achive direct connections regardless.
For this purpose you can force Ames to use a custom port. `/bin/start-urbit --port=$AMES_PORT` can be passed as an argument to the `docker start` command. Passing `/bin/start-urbit --port=13436` for example, would use port 13436. You must pass the name of the start script `/bin/start-urbit` in order to also pass arguments, if this is omitted your container will not start.
### 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 the default host port 34343:
```
docker run -d -p 8080:80 -p 34343: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:
```
docker run -d -p 8088:80 -p 23232:23232/udp --name sampel-palnet \
--mount type=volume,source=sampel-palnet,destination=/urbit \
tloncorp/urbit /bin/start-urbit --port=23232
```
### 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

View File

@ -1,5 +1,5 @@
name: urbit-king
version: 1.4
version: 1.5
license: MIT
license-file: LICENSE
data-files:

View File

@ -32,7 +32,7 @@ same (if [developing on a local development ship][local]). Then, from
'pkg/interface':
```
npm install
npm ci
npm run start
```
@ -59,7 +59,7 @@ module.exports = {
```
The dev environment will attempt to match the subdomain against the keys of this
object, and if matched will proxy to the corresponding URL. For example, the
object, and if matched will proxy to the corresponding URL. For example, the
above config will proxy `zod.localhost:9000` to `http://localhost:8080`,
`bus.localhost:9000` to `http://localhost:8081` and so on and so forth. If no
match is found, then it will fallback to the `URL` property.
@ -71,7 +71,7 @@ linter and for usage through the command, do the following:
```bash
$ cd ./pkg/interface
$ npm install
$ npm ci
$ npm run lint
```

View File

@ -98,8 +98,9 @@
"lint-file": "eslint",
"tsc": "tsc",
"tsc:watch": "tsc --watch",
"preinstall": "./preinstall.sh",
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
"build:prod": "cd ../npm/api && npm i && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"build:prod": "cd ../npm/api && npm ci && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"start": "webpack-dev-server --config config/webpack.dev.js",
"test": "echo \"Error: no test specified\" && exit 1"
},

12
pkg/interface/preinstall.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/sh
cd ../npm
for i in $(find . -type d -maxdepth 1) ; do
packageJson="${i}/package.json"
if [ -f "${packageJson}" ]; then
echo "installing ${i}..."
cd ./${i}
npm ci
cd ..
fi
done

View File

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

View File

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

View File

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

View File

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

View File

@ -41,6 +41,12 @@ export function useLazyScroll(
}
}, [count]);
useEffect(() => {
if(!ready) {
setIsDone(false);
}
}, [ready]);
useEffect(() => {
if (!ref.current || isDone || !ready) {
return;
@ -58,7 +64,7 @@ export function useLazyScroll(
return () => {
ref.current?.removeEventListener('scroll', onScroll);
};
}, [ref?.current, count, ready]);
}, [ref?.current, ready, isDone]);
return { isDone, isLoading };
}

View File

@ -10,7 +10,7 @@ export function useRunIO<I, O>(
io: (i: I) => Promise<O>,
after: (o: O) => void,
key: string
) {
): () => Promise<void> {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
const [output, setOutput] = useState<O | null>(null);

View File

@ -63,6 +63,16 @@ export function unixToDa(unix: number) {
return DA_UNIX_EPOCH.add(timeSinceEpoch);
}
export function dmCounterparty(resource: string) {
const [,,ship,name] = resource.split('/');
return ship === `~${window.ship}` ? `~${name.slice(4)}` : ship;
}
export function isDm(resource: string) {
const [,,,name] = resource.split('/');
return name.startsWith('dm--');
}
export function makePatDa(patda: string) {
return bigInt(udToDec(patda));
}

View File

@ -8,12 +8,6 @@ export interface ContactState extends BaseState<ContactState> {
isContactPublic: boolean;
nackedContacts: Set<Patp>;
// fetchIsAllowed: (entity, name, ship, personal) => Promise<boolean>;
};
export function useContact(ship: string) {
return useContactState(
useCallback(s => s.contacts[ship] as Contact | null, [ship])
);
}
const useContactState = createState<ContactState>('Contact', {
@ -35,4 +29,10 @@ const useContactState = createState<ContactState>('Contact', {
// },
}, ['nackedContacts']);
export function useContact(ship: string) {
return useContactState(
useCallback(s => s.contacts[ship] as Contact | null, [ship])
);
}
export default useContactState;

View File

@ -16,7 +16,7 @@ const useGroupState = createState<GroupState>('Group', {
}, ['groups']);
export function useGroup(group: string) {
return useGroupState(useCallback(s => s.groups[group], [group]));
return useGroupState(useCallback(s => s.groups[group] as Group | undefined, [group]));
}
export function useGroupForAssoc(association: Association) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
/* eslint-disable max-lines-per-function */
import bigInt from 'big-integer';
import React, {
useState,
useEffect,
@ -19,7 +20,8 @@ import {
writeText,
useShowNickname,
useHideAvatar,
useHovering
useHovering,
daToUnix
} from '~/logic/lib/util';
import {
Group,
@ -295,15 +297,20 @@ class ChatMessage extends Component<ChatMessageProps> {
);
}
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const nextDate = nextMsg ? (
daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null;
const dayBreak =
nextMsg &&
new Date(msg['time-sent']).getDate() !==
new Date(nextMsg['time-sent']).getDate();
new Date(date).getDate() !==
new Date(nextDate).getDate();
const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
const timestamp = moment
.unix(msg['time-sent'] / 1000)
.unix(date / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm');
const messageProps = {
@ -339,7 +346,7 @@ class ChatMessage extends Component<ChatMessageProps> {
style={style}
>
{dayBreak && !isLastRead ? (
<DayBreak when={msg['time-sent']} shimTop={renderSigil} />
<DayBreak when={date} shimTop={renderSigil} />
) : null}
{renderSigil ? (
<MessageWrapper {...messageProps}>
@ -357,7 +364,7 @@ class ChatMessage extends Component<ChatMessageProps> {
association={association}
api={api}
dayBreak={dayBreak}
when={msg['time-sent']}
when={date}
ref={unreadMarkerRef}
/>
) : null}
@ -387,8 +394,10 @@ export const MessageAuthor = ({
const dark = theme === 'dark' || (theme === 'auto' && osDark);
const contacts = useContactState((state) => state.contacts);
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const datestamp = moment
.unix(msg['time-sent'] / 1000)
.unix(date / 1000)
.format(DATESTAMP_FORMAT);
const contact =
((msg.author === window.ship && showOurContact) ||

View File

@ -98,8 +98,15 @@ h2 {
font-family: 'Inter', sans-serif;
}
.embed-container:not(.embed-container .embed-container):not(.links) {
padding: 0px 8px 8px 8px;
}
.embed-container iframe {
max-width: 100%;
width: 100%;
height: 100%;
margin-top: 8px;
}
.mh-16 {

View File

@ -46,7 +46,7 @@ const ScrollbarLessBox = styled(Box)`
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
export default function LaunchApp(props) {
const connection = { props };
const { connection } = props;
const baseHash = useLaunchState(state => state.baseHash);
const [hashText, setHashText] = useState(baseHash);
const [exitingTut, setExitingTut] = useState(false);

View File

@ -1,65 +1,80 @@
import React, { ReactNode, useCallback } from 'react';
import moment from 'moment';
import { Row, Box, Col, Text, Anchor, Icon, Action } from '@tlon/indigo-react';
import { Link, useHistory } from 'react-router-dom';
import _ from 'lodash';
import React, { ReactNode, useCallback } from "react";
import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import { Link, useHistory } from "react-router-dom";
import _ from "lodash";
import {
GraphNotifIndex,
GraphNotificationContents,
Associations,
Rolodex,
Groups
} from '~/types';
import { Header } from './header';
import { cite, deSig, pluralize, useShowNickname } from '~/logic/lib/util';
import Author from '~/views/components/Author';
import GlobalApi from '~/logic/api/global';
import { getSnippet } from '~/logic/lib/publish';
import styled from 'styled-components';
import { MentionText } from '~/views/components/MentionText';
import ChatMessage from '../chat/components/ChatMessage';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
import {PermalinkEmbed} from '../permalinks/embed';
import {parsePermalink, referenceToPermalink} from '~/logic/lib/permalinks';
Groups,
} from "~/types";
import { Header } from "./header";
import {
cite,
deSig,
pluralize,
useShowNickname,
isDm,
} from "~/logic/lib/util";
import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
import Author from "~/views/components/Author";
import GlobalApi from "~/logic/api/global";
import styled from "styled-components";
import useContactState from "~/logic/state/contact";
import useGroupState from "~/logic/state/group";
import useMetadataState, {
useAssocForGraph,
useAssocForGroup,
} from "~/logic/state/metadata";
import { PermalinkEmbed } from "../permalinks/embed";
import { parsePermalink, referenceToPermalink } from "~/logic/lib/permalinks";
import { Post, Group, Association } from "@urbit/api";
import { BigInteger } from "big-integer";
const TruncBox = styled(Box)<{ truncate?: number }>`
-webkit-line-clamp: ${(p) => p.truncate ?? "unset"};
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
color: ${(p) => p.theme.colors.black};
`;
function getGraphModuleIcon(module: string) {
if (module === 'link') {
return 'Collection';
if (module === "link") {
return "Collection";
}
if(module === 'post') {
return 'Groups';
if (module === "post") {
return "Groups";
}
return _.capitalize(module);
}
const FilterBox = styled(Box)`
background: linear-gradient(
to bottom,
transparent,
${(p) => p.theme.colors.white}
);
`;
function describeNotification(description: string, plural: boolean): string {
function describeNotification(
description: string,
plural: boolean,
isDm: boolean,
singleAuthor: boolean
): string {
switch (description) {
case 'post':
return 'replied to you';
case 'link':
return `added ${pluralize('new link', plural)} to`;
case 'comment':
return `left ${pluralize('comment', plural)} on`;
case 'edit-comment':
return `updated ${pluralize('comment', plural)} on`;
case 'note':
return `posted ${pluralize('note', plural)} to`;
case 'edit-note':
return `updated ${pluralize('note', plural)} in`;
case 'mention':
return 'mentioned you on';
case 'message':
return `sent ${pluralize('message', plural)} to`;
case "post":
return singleAuthor ? "replied to you" : "Your post received replies";
case "link":
return `New link${plural ? "s" : ""} in`;
case "comment":
return `New comment${plural ? "s" : ""} on`;
case "note":
return `New Note${plural ? "s" : ""} in`;
case "edit-note":
return `updated ${pluralize("note", plural)} in`;
case "mention":
return singleAuthor ? "mentioned you in" : "You were mentioned in";
case "message":
if (isDm) {
return "messaged you";
}
return `New message${plural ? "s" : ""} in`;
default:
return description;
}
@ -67,105 +82,87 @@ function describeNotification(description: string, plural: boolean): string {
const GraphUrl = ({ contents, api }) => {
const [{ text }, link] = contents;
if('reference' in link) {
if ("reference" in link) {
return (
<PermalinkEmbed
<PermalinkEmbed
transcluded={1}
link={referenceToPermalink(link).link}
api={api}
showOurContact
/>);
/>
);
}
return (
<Box borderRadius='2' p='2' bg='scales.black05'>
<Anchor underline={false} target='_blank' color='black' href={link.url}>
<Icon verticalAlign='bottom' mr='2' icon='ArrowExternal' />
<Box borderRadius="2" p="2" bg="scales.black05">
<Anchor underline={false} target="_blank" color="black" href={link.url}>
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{text}
</Anchor>
</Box>
);
};
function ContentSummary({ icon, name, author, to }) {
return (
<Link to={to}>
<Col
gapY="1"
flexDirection={["column", "row"]}
alignItems={["flex-start", "center"]}
>
<Row
alignItems="center"
gapX="2"
p="1"
width="fit-content"
borderRadius="2"
border="1"
borderColor="lightGray"
>
<Icon display="block" icon={icon} />
<Text verticalAlign="baseline" fontWeight="medium">
{name}
</Text>
</Row>
<Row ml={[0, 1]} alignItems="center">
<Text lineHeight="1" fontWeight="medium" mr="1">
by
</Text>
<Author
sigilPadding={6}
size={24}
dontShowTime
ship={author}
showImage
/>
</Row>
</Col>
</Link>
);
}
export const GraphNodeContent = ({
group,
association,
post,
mod,
index,
}) => {
export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
const { contents } = post;
const idx = index.slice(1).split('/');
if (mod === 'link') {
if (idx.length === 1) {
return <GraphUrl contents={contents} />;
} else if (idx.length === 3) {
return <MentionText content={contents} group={group} />;
}
return null;
}
if (mod === 'publish') {
if (idx[1] === '2') {
return (
<MentionText
content={contents}
group={group}
fontSize='14px'
lineHeight='tall'
/>
);
} else if (idx[1] === '1') {
const [{ text: header }, { text: body }] = contents;
const snippet = getSnippet(body);
return (
<Col>
<Box mb='2' fontWeight='500'>
<Text>{header}</Text>
</Box>
<Box overflow='hidden' maxHeight='400px' position='relative'>
<Text lineHeight='tall'>{snippet}</Text>
<FilterBox
width='100%'
zIndex='1'
height='calc(100% - 2em)'
bottom='-4px'
position='absolute'
/>
</Box>
</Col>
);
}
}
if(mod === 'post') {
return <MentionText content={contents} group={group} />;
}
if (mod === 'chat') {
const idx = index.slice(1).split("/");
const url = getNodeUrl(mod, hidden, association?.group, association?.resource, index);
if (mod === "link" && idx.length === 1) {
const [{ text: title }] = contents;
return (
<Row
width='100%'
flexShrink={0}
flexGrow={1}
flexWrap='wrap'
marginLeft='-32px'
>
<ChatMessage
renderSigil={false}
containerClass='items-top cf hide-child'
group={group}
groups={{}}
association={association}
associations={{ graph: {}, groups: {} }}
msg={post}
fontSize='0'
pt='2'
hideHover={true}
/>
</Row>
<ContentSummary to={url} icon="Links" name={title} author={post.author} />
);
}
return null;
if (mod === "publish" && idx[1] === "1") {
const [{ text: title }] = contents;
return (
<ContentSummary to={url} icon="Note" name={title} author={post.author} />
);
}
return (
<TruncBox truncate={8}>
<GraphContentWide api={{} as any} post={post} showOurContact />
</TruncBox>
);
};
function getNodeUrl(
@ -175,78 +172,103 @@ function getNodeUrl(
graph: string,
index: string
) {
if (hidden && mod === 'chat') {
groupPath = '/messages';
if (hidden && mod === "chat") {
groupPath = "/messages";
} else if (hidden) {
groupPath = '/home';
groupPath = "/home";
}
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
const idx = index.slice(1).split('/');
if (mod === 'publish') {
const [noteId] = idx;
return `${graphUrl}/note/${noteId}`;
} else if (mod === 'link') {
const [linkId] = idx;
return `${graphUrl}/index/${linkId}`;
} else if (mod === 'chat') {
if(idx.length > 0) {
const idx = index.slice(1).split("/");
if (mod === "publish") {
console.log(idx);
const [noteId, kind, commId] = idx;
const selected = kind === "2" ? `?selected=${commId}` : "";
return `${graphUrl}/note/${noteId}${selected}`;
} else if (mod === "link") {
const [linkId, commId] = idx;
return `${graphUrl}/index/${linkId}${commId ? `?selected=${commId}` : ""}`;
} else if (mod === "chat") {
if (idx.length > 0) {
return `${graphUrl}?msg=${idx[0]}`;
}
return graphUrl;
} else if( mod === 'post') {
} else if (mod === "post") {
return `/~landscape${groupPath}/feed${index}`;
}
return '';
return "";
}
const GraphNode = ({
post,
author,
mod,
description,
time,
index,
graph,
groupPath,
group,
read,
onRead,
showContact = false
}) => {
author = deSig(author);
const history = useHistory();
const contacts = useContactState((state) => state.contacts);
const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index);
const association = useMetadataState(
useCallback(s => s.associations.graph[graph], [graph])
interface PostsByAuthor {
author: string;
posts: Post[];
}
const GraphNodes = (props: {
posts: Post[];
graph: string;
hideAuthors?: boolean;
group?: Group;
groupPath: string;
description: string;
index: string;
mod: string;
association: Association;
hidden: boolean;
}) => {
const {
posts,
mod,
hidden,
index,
description,
hideAuthors = false,
association,
} = props;
const postsByConsecAuthor = _.reduce(
posts,
(acc: PostsByAuthor[], val: Post, key: number) => {
const lent = acc.length;
if (lent > 0 && acc?.[lent - 1]?.author === val.author) {
const last = acc[lent - 1];
const rest = acc.slice(0, -1);
return [...rest, { ...last, posts: [...last.posts, val] }];
}
return [...acc, { author: val.author, posts: [val] }];
},
[]
);
const onClick = useCallback(() => {
if (!read) {
onRead();
}
history.push(nodeUrl);
}, [read, onRead]);
return (
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}>
<Col flexGrow={1} alignItems='flex-start'>
{showContact && (
<Author showImage ship={author} date={time} group={group} />
)}
<Row width='100%' p='1' flexDirection='column'>
<GraphNodeContent
post={post}
mod={mod}
description={description}
association={association}
index={index}
group={group}
remoteContentPolicy={{}}
/>
</Row>
</Col>
</Row>
<>
{_.map(postsByConsecAuthor, ({ posts, author }, idx) => {
const time = posts[0]?.["time-sent"];
return (
<Col key={idx} flexGrow={1} alignItems="flex-start">
{!hideAuthors && (
<Author
size={24}
sigilPadding={6}
showImage
ship={author}
date={time}
/>
)}
<Col gapY="2" py={hideAuthors ? 0 : 2} width="100%">
{_.map(posts, (post) => (
<GraphNodeContent
key={post.index}
post={post}
mod={mod}
index={index}
association={association}
hidden={hidden}
/>
))}
</Col>
</Col>
);
})}
</>
);
};
@ -260,53 +282,79 @@ export function GraphNotification(props: {
api: GlobalApi;
}) {
const { contents, index, read, time, api, timebox } = props;
const history = useHistory();
const authors = _.map(contents, 'author');
const authors = _.uniq(_.map(contents, "author"));
const singleAuthor = authors.length === 1;
const { graph, group } = index;
const icon = getGraphModuleIcon(index.module);
const desc = describeNotification(index.description, contents.length !== 1);
const association = useAssocForGraph(graph)!;
const dm = isDm(graph);
const desc = describeNotification(
index.description,
contents.length !== 1,
dm,
singleAuthor
);
const groupAssociation = useAssocForGroup(association?.group);
const groups = useGroupState((state) => state.groups);
const onClick = useCallback(() => {
if (props.archived || read) {
return;
if (
!(
(index.description === "note" || index.description === "link") &&
index.index === "/"
)
) {
const first = contents[0];
history.push(
getNodeUrl(
index.module,
groups[association?.group]?.hidden,
group,
association?.resource,
first.index
)
);
}
return api.hark['read'](timebox, { graph: index });
}, [api, timebox, index, read]);
const groups = useGroupState((state) => state.groups);
const authorsInHeader =
dm ||
((index.description === "mention" || index.description === "post") &&
singleAuthor);
const hideAuthors =
authorsInHeader ||
index.description === "note" ||
index.description === "link";
const channelTitle = dm ? undefined : association?.metadata?.title ?? graph;
const groupTitle = groupAssociation?.metadata?.title;
return (
<>
<Header
onClick={onClick}
archived={props.archived}
time={time}
read={read}
authors={authors}
moduleIcon={icon}
channel={graph}
group={group}
authors={authorsInHeader ? authors : []}
channelTitle={channelTitle}
description={desc}
groupTitle={groupTitle}
content
/>
<Box flexGrow={1} width='100%' pl={5} gridArea='main'>
{_.map(contents, (content, idx) => (
<GraphNode
post={content}
author={content.author}
mod={index.module}
time={content?.['time-sent']}
description={index.description}
index={content.index}
graph={graph}
group={groups[group]}
groupPath={group}
read={read}
onRead={onClick}
showContact={idx === 0}
/>
))}
</Box>
<Col onClick={onClick} gapY="2" flexGrow={1} width="100%" gridArea="main">
<GraphNodes
hideAuthors={hideAuthors}
posts={contents.slice(0, 4)}
mod={index.module}
description={index.description}
index={contents?.[0].index}
association={association}
hidden={groups[association?.group]?.hidden}
/>
{contents.length > 4 && (
<Text mb="2" gray>
+ {contents.length - 4} more
</Text>
)}
</Col>
</>
);
}

View File

@ -12,6 +12,7 @@ import {
import { Header } from './header';
import GlobalApi from '~/logic/api/global';
import {useAssocForGroup} from '~/logic/state/metadata';
function describeNotification(description: string, plural: boolean) {
switch (description) {
@ -52,23 +53,16 @@ export function GroupNotification(props: GroupNotificationProps): ReactElement {
const { group } = index;
const desc = describeNotification(index.description, contents.length !== 1);
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? 'unread' : 'read';
return api.hark[func](timebox, { group: index });
}, [api, timebox, index, read]);
const association = useAssocForGroup(group)
const groupTitle = association?.metadata?.title ?? group;
return (
<Col onClick={onClick} p="2">
<Col>
<Header
archived={props.archived}
time={time}
read={read}
group={group}
authors={authors}
description={desc}
groupTitle={groupTitle}
/>
</Col>
);

View File

@ -1,103 +1,90 @@
import React, { ReactElement } from 'react';
import f from 'lodash/fp';
import _ from 'lodash';
import moment from 'moment';
import React, { ReactElement } from "react";
import _ from "lodash";
import moment from "moment";
import { Text as NormalText, Row, Rule, Box, Col } from "@tlon/indigo-react";
import { Text as NormalText, Row, Icon, Rule } from '@tlon/indigo-react';
import { Associations, Contact, Contacts, Rolodex } from '@urbit/api';
import { PropFunc } from '~/types/util';
import { useShowNickname } from '~/logic/lib/util';
import Timestamp from '~/views/components/Timestamp';
import useContactState from '~/logic/state/contact';
import useMetadataState from '~/logic/state/metadata';
import { PropFunc } from "~/types/util";
import Timestamp from "~/views/components/Timestamp";
import Author from "~/views/components/Author";
import Dot from "~/views/components/Dot";
const Text = (props: PropFunc<typeof Text>) => (
<NormalText fontWeight="500" {...props} />
);
function Author(props: { patp: string; last?: boolean }): ReactElement {
const contacts = useContactState(state => state.contacts);
const contact: Contact | undefined = contacts?.[`~${props.patp}`];
const showNickname = useShowNickname(contact);
const name = showNickname ? contact.nickname : `~${props.patp}`;
export function Header(
props: {
channelTitle?: string;
groupTitle?: string;
description: string;
time?: number;
authors?: string[];
content?: boolean;
} & PropFunc<typeof Row>
): ReactElement {
const {
description,
channelTitle = "",
groupTitle,
authors = [],
content = false,
time,
} = props;
return (
<Text mono={!showNickname}>
{name}
{!props.last && ', '}
</Text>
);
}
export function Header(props: {
authors: string[];
archived?: boolean;
channel?: string;
group: string;
description: string;
moduleIcon?: string;
time: number;
read: boolean;
} & PropFunc<typeof Row> ): ReactElement {
const { description, channel, moduleIcon, read } = props;
const associations = useMetadataState(state => state.associations);
const authors = _.uniq(props.authors);
const authorDesc = f.flow(
f.take(3),
f.entries,
f.map(([idx, p]: [string, string]) => {
const lent = Math.min(3, authors.length);
const last = lent - 1 === parseInt(idx, 10);
return <Author key={idx} patp={p} last={last} />;
}),
auths => (
<React.Fragment>
{auths}
{authors.length > 3 &&
` and ${authors.length - 3} other${authors.length === 4 ? '' : 's'}`}
</React.Fragment>
)
)(authors);
const time = moment(props.time).format('HH:mm');
const groupTitle =
associations.groups?.[props.group]?.metadata?.title;
const app = 'graph';
const channelTitle =
(channel && associations?.[app]?.[channel]?.metadata?.title) ||
channel;
return (
<Row onClick={props.onClick} p="2" flexWrap="wrap" alignItems="center" gridArea="header">
{!props.archived && (
<Icon
display="block"
opacity={read ? 0 : 1}
mr={2}
icon="Bullet"
color="blue"
/>
)}
<Text mr="1" mono>
{authorDesc}
</Text>
<Text mr="1">{description}</Text>
{Boolean(moduleIcon) && <Icon icon={moduleIcon as any} mr={1} />}
{Boolean(channel) && <Text fontWeight="500" mr={1}>{channelTitle}</Text>}
<Rule vertical height="12px" mr={1} />
{groupTitle &&
<>
<Text fontWeight="500" mr={1}>{groupTitle}</Text>
<Rule vertical height="12px" mr={1} />
</>
}
<Timestamp stamp={moment(props.time)} color="lightGray" date={false} />
<Row
flexDirection={["column-reverse", "row"]}
minHeight="4"
mb={content ? 2 : 0}
onClick={props.onClick}
flexWrap="wrap"
alignItems={["flex-start", "center"]}
gridArea="header"
overflow="hidden"
>
<Row gapX="1" overflow="hidden" alignItems="center">
{authors.length > 0 && (
<>
<Author
flexShrink={0}
sigilPadding={6}
size={24}
dontShowTime
date={time}
ship={authors[0]}
showImage
/>
{authors.length > 1 && (
<Text lineHeight="tall">+ {authors.length - 1} more</Text>
)}
</>
)}
<Box whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
<Text lineHeight="tall" mr="1">
{description} {channelTitle}
</Text>
</Box>
</Row>
<Row ml={[0, 1]} mb={[1, 0]} gapX="1" alignItems="center">
{groupTitle && (
<>
<Text lineHeight="tall" fontSize="1" gray>
{groupTitle}
</Text>
<Dot color="gray" />
</>
)}
{time && (
<Timestamp
lineHeight="tall"
fontSize="1"
relative
stamp={moment(time)}
color="gray"
date={false}
/>
)}
</Row>
</Row>
);
}

View File

@ -121,7 +121,7 @@ export default function Inbox(props: {
);
return (
<Col ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Col p="1" ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!;
@ -175,26 +175,15 @@ function DaySection({
return (
<>
<Box position="sticky" zIndex={3} top="-1px" bg="white">
<Box p="2" bg="scales.black05">
<Text>
{label}
</Text>
</Box>
</Box>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}>
{(i !== 0 || j !== 0) && (
<Box flexShrink={0} height="4px" bg="scales.black05" />
)}
<Notification
api={api}
notification={not}
archived={archive}
time={date}
/>
</React.Fragment>
<Notification
key={j}
api={api}
notification={not}
archived={archive}
time={date}
/>
))
)}
</>

View File

@ -59,13 +59,6 @@ export function Invites(props: InvitesProps): ReactElement {
return (
<>
{Object.keys(invitesAndStatus).length > 0 && (
<Box position="sticky" zIndex={3} top="-1px" bg="white" flexShrink="0">
<Box p="2" bg="scales.black05">
<Text>Invites</Text>
</Box>
</Box>
)}
{Object.keys(invitesAndStatus)
.sort(alphabeticalOrder)
.map((resource) => {
@ -89,10 +82,9 @@ export function Invites(props: InvitesProps): ReactElement {
invite={invite}
app={app}
uid={uid}
join={join}
resource={resource}
/>
);
);
}
})
}

View File

@ -1,6 +1,6 @@
import React, { ReactNode, useCallback, useMemo, useState } from 'react';
import { Row, Box } from '@tlon/indigo-react';
import _ from 'lodash';
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { Row, Box, Icon } from "@tlon/indigo-react";
import _ from "lodash";
import {
GraphNotificationContents,
IndexedNotification,
@ -9,16 +9,17 @@ import {
GroupNotificationsConfig,
Groups,
Associations,
Contacts
} from '@urbit/api';
import GlobalApi from '~/logic/api/global';
import { getParentIndex } from '~/logic/lib/notification';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { GroupNotification } from './group';
import { GraphNotification } from './graph';
import { BigInteger } from 'big-integer';
import { useHovering } from '~/logic/lib/util';
import useHarkState from '~/logic/state/hark';
Contacts,
} from "@urbit/api";
import GlobalApi from "~/logic/api/global";
import { getParentIndex } from "~/logic/lib/notification";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { GroupNotification } from "./group";
import { GraphNotification } from "./graph";
import { BigInteger } from "big-integer";
import { useHovering } from "~/logic/lib/util";
import useHarkState from "~/logic/state/hark";
import {IS_MOBILE} from "~/logic/lib/platform";
interface NotificationProps {
notification: IndexedNotification;
@ -33,72 +34,98 @@ function getMuted(
graphs: NotificationGraphConfig
) {
const { index, notification } = idxNotif;
if ('graph' in idxNotif.index) {
if ("graph" in idxNotif.index) {
const { graph } = idxNotif.index.graph;
if(!('graph' in notification.contents)) {
if (!("graph" in notification.contents)) {
throw new Error();
}
const parent = getParentIndex(index.graph, notification.contents.graph);
return _.findIndex(
graphs?.watching || [],
g => g.graph === graph && g.index === parent
) === -1;
return (
_.findIndex(
graphs?.watching || [],
(g) => g.graph === graph && g.index === parent
) === -1
);
}
if ('group' in index) {
return _.findIndex(groups || [], g => g === index.group.group) === -1;
if ("group" in index) {
return _.findIndex(groups || [], (g) => g === index.group.group) === -1;
}
return false;
}
function NotificationWrapper(props: {
export function NotificationWrapper(props: {
api: GlobalApi;
time: BigInteger;
notif: IndexedNotification;
time?: BigInteger;
notification?: IndexedNotification;
children: ReactNode;
archived: boolean;
}) {
const { api, time, notif, children } = props;
const { api, time, notification, children } = props;
const onArchive = useCallback(async () => {
return api.hark.archive(time, notif.index);
}, [time, notif]);
if (!(time && notification)) {
return;
}
return api.hark.archive(time, notification.index);
}, [time, notification]);
const groupConfig = useHarkState(state => state.notificationsGroupConfig);
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const groupConfig = useHarkState((state) => state.notificationsGroupConfig);
const graphConfig = useHarkState((state) => state.notificationsGraphConfig);
const isMuted = getMuted(
notif,
groupConfig,
graphConfig
);
const isMuted =
time && notification && getMuted(notification, groupConfig, graphConfig);
const onChangeMute = useCallback(async () => {
const func = isMuted ? 'unmute' : 'mute';
return api.hark[func](notif);
}, [notif, api, isMuted]);
if (!notification) {
return;
}
const func = isMuted ? "unmute" : "mute";
return api.hark[func](notification);
}, [notification, api, isMuted]);
const onClick = () => {
if (!(time && notification) || notification.notification.read) {
return;
}
return api.hark.read(time, notification.index);
};
const { hovering, bind } = useHovering();
const changeMuteDesc = isMuted ? 'Unmute' : 'Mute';
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
return (
<Box
width="100%"
onClick={onClick}
bg={
(notification ? notification?.notification?.read : false)
? "washedGray"
: "washedBlue"
}
borderRadius={2}
display="grid"
gridTemplateColumns="1fr 200px"
gridTemplateColumns={["1fr 24px", "1fr 200px"]}
gridTemplateRows="auto"
gridTemplateAreas="'header actions' 'main main'"
pb={2}
p={2}
m={2}
{...bind}
>
{children}
<Row gapX="2" p="2" pt='3' gridArea="actions" justifyContent="flex-end" opacity={[1, hovering ? 1 : 0]}>
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute} backgroundColor="transparent">
{changeMuteDesc}
</StatelessAsyncAction>
{!props.archived && (
<StatelessAsyncAction name={time.toString()} onClick={onArchive} backgroundColor="transparent">
Dismiss
<Row
alignItems="flex-start"
gapX="2"
gridArea="actions"
justifyContent="flex-end"
opacity={[1, (hovering || IS_MOBILE) ? 1 : 0]}
>
{time && notification && (
<StatelessAsyncAction
name={time.toString()}
borderRadius={1}
onClick={onArchive}
backgroundColor="white"
>
<Icon lineHeight="24px" size={16} icon="X" />
</StatelessAsyncAction>
)}
</Row>
@ -110,23 +137,18 @@ export function Notification(props: NotificationProps) {
const { notification, associations, archived } = props;
const { read, contents, time } = notification.notification;
const Wrapper = ({ children }) => (
<NotificationWrapper
archived={archived}
notif={notification}
time={props.time}
api={props.api}
>
{children}
</NotificationWrapper>
);
const wrapperProps = {
notification,
time: props.time,
api: props.api,
};
if ('graph' in notification.index) {
if ("graph" in notification.index) {
const index = notification.index.graph;
const c: GraphNotificationContents = (contents as any).graph;
return (
<Wrapper>
<NotificationWrapper {...wrapperProps}>
<GraphNotification
api={props.api}
index={index}
@ -136,14 +158,14 @@ export function Notification(props: NotificationProps) {
timebox={props.time}
time={time}
/>
</Wrapper>
</NotificationWrapper>
);
}
if ('group' in notification.index) {
if ("group" in notification.index) {
const index = notification.index.group;
const c: GroupNotificationContents = (contents as any).group;
return (
<Wrapper>
<NotificationWrapper {...wrapperProps}>
<GroupNotification
api={props.api}
index={index}
@ -153,7 +175,7 @@ export function Notification(props: NotificationProps) {
archived={archived}
time={time}
/>
</Wrapper>
</NotificationWrapper>
);
}

View File

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

View File

@ -64,20 +64,20 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
{contact?.cover ? (
<div>
{editCover ? (
<ImageInput id='cover' marginTop='-8px' />
<ImageInput id='cover' marginTop='-8px' width='288px' />
) : (
<Row>
<Button mr='2' onClick={() => setEditCover(true)}>
Replace Header
</Button>
<Button onClick={(e) => handleClear(e)}>
<Button onClick={e => handleClear(e)}>
{removedCoverLabel}
</Button>
</Row>
)}
</div>
) : (
<ImageInput id='cover' marginTop='-8px' />
<ImageInput id='cover' marginTop='-8px' width='288px' />
)}
</>
);

View File

@ -134,7 +134,13 @@ export function ProfileActions(props: any): ReactElement {
history.push(`/~profile/${ship}/edit`);
}}
>
Edit {isPublic ? 'Public' : 'Private'} Profile
Edit
<Text
fontWeight='500'
cursor='pointer'
display={['none','inline']}>
{isPublic ? ' Public' : ' Private'} Profile
</Text>
</Text>
<SetStatusBarModal
isControl
@ -183,7 +189,7 @@ export function Profile(props: any): ReactElement | null {
}
return (
<Center p={[0, 4]} height='100%' width='100%'>
<Center p={[3, 4]} height='100%' width='100%'>
<Box maxWidth='600px' width='100%' position='relative'>
{ isEdit ? (
<EditProfile

View File

@ -49,20 +49,20 @@
font-family: 'Source Code Pro';
}
.publish .CodeMirror-selected { background:#BAE3FE !important; color: black; }
.publish .CodeMirror-selected { background:#BAE3FE !important; color: inherit; }
.publish .cm-s-tlon span { font-family: "Source Code Pro"}
.publish .cm-s-tlon span.cm-meta { color: var(--gray); }
.publish .cm-s-tlon span.cm-number { color: var(--gray); }
.publish .cm-s-tlon span.cm-keyword { line-height: 1em; font-weight: bold; color: var(--gray); }
.publish .cm-s-tlon span.cm-atom { font-weight: bold; color: var(--gray); }
.publish .cm-s-tlon span.cm-def { color: black; }
.publish .cm-s-tlon span.cm-variable { color: black; }
.publish .cm-s-tlon span.cm-variable-2 { color: black; }
.publish .cm-s-tlon span.cm-variable-3, .publish .cm-s-tlon span.cm-type { color: black; }
.publish .cm-s-tlon span.cm-property { color: black; }
.publish .cm-s-tlon span.cm-operator { color: black; }
.publish .cm-s-tlon span.cm-comment { color: black; background-color: var(--light-gray); display: inline-block; border-radius: 2px;}
.publish .cm-s-tlon span.cm-def { color: inherit; }
.publish .cm-s-tlon span.cm-variable { color: inherit; }
.publish .cm-s-tlon span.cm-variable-2 { color: inherit; }
.publish .cm-s-tlon span.cm-variable-3, .publish .cm-s-tlon span.cm-type { color: inherit; }
.publish .cm-s-tlon span.cm-property { color: inherit; }
.publish .cm-s-tlon span.cm-operator { color: inherit; }
.publish .cm-s-tlon span.cm-comment { color: inherit; background-color: var(--light-gray); display: inline-block; border-radius: 2px;}
.publish .cm-s-tlon span.cm-string { color: var(--dark-gray); }
.publish .cm-s-tlon span.cm-string-2 { color: var(--gray); }
.publish .cm-s-tlon span.cm-qualifier { color: #555; }

View File

@ -1,32 +1,36 @@
import React, { ReactElement } from 'react';
import {
Box,
Text,
Row,
Label,
Col,
ManagedRadioButtonField as Radio,
ManagedRadioButtonField as Radio
} from '@tlon/indigo-react';
import GlobalApi from '~/logic/api/global';
import { ImageInput } from '~/views/components/ImageInput';
import { ColorInput } from '~/views/components/ColorInput';
import { StorageState } from '~/types';
export type BgType = 'none' | 'url' | 'color';
export function BackgroundPicker({
bgType,
bgUrl,
api,
api
}: {
bgType: BgType;
bgUrl?: string;
api: GlobalApi;
}): ReactElement {
const rowSpace = { my: 0, alignItems: 'center' };
const colProps = { my: 3, mr: 4, gapY: 1 };
const colProps = {
my: 3,
mr: 4,
gapY: 1,
minWidth: '266px',
width: ['100%', '288px']
};
return (
<Col>
<Label>Landscape Background</Label>
@ -40,7 +44,7 @@ export function BackgroundPicker({
id="bgUrl"
placeholder="Drop or upload a file, or paste a link here"
name="bgUrl"
url={bgUrl || ""}
url={bgUrl || ''}
/>
</Col>
</Row>
@ -48,13 +52,13 @@ export function BackgroundPicker({
<Col {...colProps}>
<Radio mb="1" label="Color" id="color" name="bgType" />
<Text ml="5" gray>Set a hex-based background</Text>
<ColorInput placeholder="FFFFFF" ml="5" id="bgColor" />
<ColorInput placeholder="FFFFFF" ml="5" id="bgColor" />
</Col>
</Row>
<Radio
my="3"
caption="Your home screen will simply render as its respective day/night mode color"
name="bgType"
name="bgType"
label="None"
id="none" />
</Col>

View File

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

View File

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

View File

@ -83,7 +83,7 @@ export default function S3Form(props: S3FormProps): ReactElement {
style={{ textDecoration: 'none' }}
borderBottom='1'
ml='1'
href='https://urbit.org/using/operations/using-your-ship/#bucket-setup'
href='https://urbit.org/using/os/s3/'
>
Learn more
</Anchor>

View File

@ -9,16 +9,16 @@ import { Group } from '@urbit/api';
import { uxToHex, cite, useShowNickname, deSig } from '~/logic/lib/util';
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
import useLocalState from "~/logic/state/local";
import OverlaySigil from './OverlaySigil';
import { Sigil } from '~/logic/lib/sigil';
import Timestamp from './Timestamp';
import useContactState from '~/logic/state/contact';
import { useCopy } from '~/logic/lib/useCopy';
import ProfileOverlay from './ProfileOverlay';
import {PropFunc} from '~/types';
import { PropFunc } from '~/types';
interface AuthorProps {
ship: string;
date: number;
date?: number;
showImage?: boolean;
children?: ReactNode;
unread?: boolean;
@ -61,6 +61,7 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
const { hideAvatars } = useSettingsState(selectCalmState);
const name = showNickname && contact ? contact.nickname : cite(ship);
const stamp = moment(date);
const { copyDisplay, doCopy, didCopy } = useCopy(`~${ship}`, name);
const [showOverlay, setShowOverlay] = useState(false);
@ -108,13 +109,17 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
ml={showImage ? 2 : 0}
color='black'
fontSize='1'
cursor='pointer'
lineHeight='tall'
fontFamily={showNickname ? 'sans' : 'mono'}
fontWeight={showNickname ? '500' : '400'}
mr={showNickname ? 0 : "2px"}
mt={showNickname ? 0 : "0px"}
onClick={doCopy}
>
{name}
{copyDisplay}
</Box>
{ !dontShowTime && (
{ !dontShowTime && time && (
<Timestamp
relative={isRelativeTime}
stamp={stamp}

View File

@ -96,7 +96,7 @@ export function CommentItem(props: CommentItemProps): ReactElement {
unread={props.unread}
group={group}
>
<Row px="2" gapX="2" alignItems="center">
<Row px="2" gapX="2" height="18px">
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
{adminLinks}
</Row>

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

@ -7,11 +7,11 @@ import {
Row,
Button,
Label,
ErrorLabel,
BaseInput
BaseInput,
Text,
Icon
} from '@tlon/indigo-react';
import { StorageState } from '~/types';
import useStorage from '~/logic/lib/useStorage';
type ImageInputProps = Parameters<typeof Box>[0] & {
@ -20,13 +20,100 @@ type ImageInputProps = Parameters<typeof Box>[0] & {
placeholder?: string;
};
const prompt = (field, uploading, meta, clickUploadButton) => {
if (!field.value && !uploading && meta.error === undefined) {
return (
<Text
black
fontWeight='500'
position='absolute'
left={2}
top={2}
style={{ pointerEvents: 'none' }}
>
Paste a link here, or{' '}
<Text
fontWeight='500'
cursor='pointer'
color='blue'
style={{ pointerEvents: 'all' }}
onClick={clickUploadButton}
>
upload
</Text>{' '}
a file
</Text>
);
}
return null;
};
const uploadingStatus = (uploading, meta) => {
if (uploading && meta.error === undefined) {
return (
<Text position='absolute' left={2} top={2} gray>
Uploading...
</Text>
);
}
return null;
};
const errorRetry = (meta, uploading, clickUploadButton) => {
if (meta.error !== undefined) {
return (
<Text
position='absolute'
left={2}
top={2}
color='red'
style={{ pointerEvents: 'none' }}
>
{meta.error}{', '}please{' '}
<Text
fontWeight='500'
cursor='pointer'
color='blue'
style={{ pointerEvents: 'all' }}
onClick={clickUploadButton}
>
retry
</Text>
</Text>
);
}
return null;
};
const clearButton = (field, uploading, clearEvt) => {
if (field.value && !uploading) {
return (
<Box
position='absolute'
right={0}
top={0}
px={1}
height='100%'
cursor='pointer'
onClick={clearEvt}
backgroundColor='white'
display='flex'
alignItems='center'
borderRadius='0 4px 4px 0'
border='1px solid'
borderColor='lightGray'
>
<Icon icon='X' />
</Box>
);
}
return null;
};
export function ImageInput(props: ImageInputProps): ReactElement {
const { id, label, caption, placeholder } = props;
const { id, label, caption } = props;
const { uploadDefault, canUpload, uploading } = useStorage();
const [field, meta, { setValue, setError }] = useField(id);
const ref = useRef<HTMLInputElement | null>(null);
const onImageUpload = useCallback(async () => {
@ -43,10 +130,14 @@ export function ImageInput(props: ImageInputProps): ReactElement {
}
}, [ref.current, uploadDefault, canUpload, setValue]);
const onClick = useCallback(() => {
const clickUploadButton = useCallback(() => {
ref.current?.click();
}, [ref]);
const clearEvt = useCallback(() => {
setValue('');
}, []);
return (
<Box display="flex" flexDirection="column" {...props}>
<Label htmlFor={id}>{label}</Label>
@ -55,25 +146,25 @@ export function ImageInput(props: ImageInputProps): ReactElement {
{caption}
</Label>
) : null}
<Row mt="2" alignItems="flex-end">
<Input
type={'text'}
hasError={meta.touched && meta.error !== undefined}
placeholder={placeholder}
{...field}
/>
<Row mt="2" alignItems="flex-end" position='relative' width='100%'>
{prompt(field, uploading, meta, clickUploadButton)}
{clearButton(field, uploading, clearEvt)}
{uploadingStatus(uploading, meta)}
{errorRetry(meta, uploading, clickUploadButton)}
<Box background='white' borderRadius={2} width='100%'>
<Input
width='100%'
type={'text'}
hasError={meta.touched && meta.error !== undefined}
{...field}
/>
</Box>
{canUpload && (
<>
<Button
type="button"
ml={1}
border={1}
borderColor="lightGray"
onClick={onClick}
flexShrink={0}
>
{uploading ? 'Uploading' : 'Upload'}
</Button>
display='none'
onClick={clickUploadButton}
/>
<BaseInput
style={{ display: 'none' }}
type="file"
@ -85,9 +176,6 @@ export function ImageInput(props: ImageInputProps): ReactElement {
</>
)}
</Row>
<ErrorLabel mt="2" hasError={Boolean(meta.touched && meta.error)}>
{meta.error}
</ErrorLabel>
</Box>
);
}

View File

@ -1,79 +1,329 @@
import React, { ReactElement, ReactNode } from 'react';
import { Text, Box, Icon, Row } from '@tlon/indigo-react';
import React, { ReactElement, ReactNode, useCallback } from "react";
import {
Text,
Box,
Icon,
Row,
LoadingSpinner,
Button,
} from "@tlon/indigo-react";
import { css } from "@styled-system/css";
import _ from "lodash";
import { useHistory } from "react-router-dom";
import { cite } from '~/logic/lib/util';
import { MetadataUpdatePreview, JoinProgress, Invite, JoinRequest } from '@urbit/api';
import { GroupSummary } from '~/views/landscape/components/GroupSummary';
import { InviteSkeleton } from './InviteSkeleton';
import { JoinSkeleton } from './JoinSkeleton';
import GlobalApi from '~/logic/api/global';
import { cite, isDm } from "~/logic/lib/util";
import {
MetadataUpdatePreview,
joinProgress,
JoinProgress,
Invite,
JoinRequest,
resourceFromPath,
Metadata,
} from "@urbit/api";
import { GroupSummary } from "~/views/landscape/components/GroupSummary";
import { NotificationWrapper } from "~/views/apps/notifications/notification";
import { Header } from "~/views/apps/notifications/header";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
import GlobalApi from "~/logic/api/global";
import { PropFunc } from "~/types";
import { MetadataIcon } from "~/views/landscape/components/MetadataIcon";
import { useContact } from "~/logic/state/contact";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import useGroupState, {useGroup} from "~/logic/state/group";
import useContactState from "~/logic/state/contact";
import useMetadataState, {useAssocForGraph} from "~/logic/state/metadata";
import useGraphState from "~/logic/state/graph";
import { useRunIO } from "~/logic/lib/useRunIO";
import { StatelessAsyncButton } from "../StatelessAsyncButton";
import styled from "styled-components";
interface GroupInviteProps {
preview: MetadataUpdatePreview;
preview?: MetadataUpdatePreview;
status?: JoinRequest;
app?: string;
uid?: string;
invite?: Invite;
resource: string;
api: GlobalApi;
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
}
export function GroupInvite(props: GroupInviteProps): ReactElement {
const { resource, api, preview, invite, status, onAccept, onDecline } = props;
const { metadata, members } = props.preview;
function Elbow(
props: { size?: number; color?: string } & PropFunc<typeof Box>
) {
const { size = 12, color = "lightGray", ...rest } = props;
let inner: ReactNode = null;
let Outer: (p: { children: ReactNode }) => JSX.Element = p => (
<>{p.children}</>
return (
<Box
{...rest}
overflow="hidden"
width={size}
height={size}
position="relative"
>
<Box
border="2px solid"
borderRadius={3}
borderColor={color}
position="absolute"
left="0px"
bottom="0px"
width={size * 2}
height={size * 2}
/>
</Box>
);
}
const description: string[] = [
"Contacting host...",
"Retrieving data...",
"Finished join",
"Unable to join, you do not have the correct permissions",
"Internal error, please file an issue",
];
function inviteUrl(hidden: boolean, resource: string, metadata?: Metadata) {
if (!hidden) {
return `/~landscape${resource}`;
}
if (metadata?.config.graph === "chat") {
return `/~landscape/messages/resource/${metadata?.config?.graph}${resource}`;
} else {
return `/~landscape/home/resource/${metadata?.config?.graph}${resource}`;
}
}
function InviteMetadata(props: {
preview?: MetadataUpdatePreview;
resource: string;
}) {
const { resource, preview } = props;
const { ship, name } = resourceFromPath(resource);
const dm = isDm(resource);
if (dm) {
return null;
}
const container = (children: ReactNode) => (
<Row overflow="hidden" height="4" gapX="2" alignItems="center">
{children}
</Row>
);
if (status) {
inner = (
<Text mr="1">
You are joining <Text fontWeight="medium">{metadata.title}</Text>
</Text>
);
Outer = ({ children }) => (
<JoinSkeleton resource={resource} api={api} gapY="3" status={status}>
{children}
</JoinSkeleton>
);
} else if (invite) {
Outer = ({ children }) => (
<InviteSkeleton
onDecline={onDecline}
onAccept={onAccept}
acceptDesc="Join Group"
declineDesc="Decline Invitation"
gapY="3"
>
{children}
</InviteSkeleton>
);
inner = (
if (preview) {
const { title } = preview.metadata;
const { members } = preview;
return container(
<>
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
<MetadataIcon height={4} width={4} metadata={preview.metadata} />
<Text fontWeight="medium">{title}</Text>
<Text gray fontWeight="medium">
{members} Member{members > 1 ? "s" : ""}
</Text>
<Text mr="1">invited you to </Text>
<Text fontWeight="medium">{metadata.title}</Text>
</>
);
}
return (
<Outer>
<Row py="1" alignItems="center">
<Icon display="block" mr={2} icon="Bullet" color="blue" />
{inner}
</Row>
<Box px="4">
<GroupSummary
gray
metadata={metadata}
memberCount={members}
channelCount={preview?.['channel-count']}
/>
</Box>
</Outer>
return container(
<>
<Text whiteSpace="nowrap" textOverflow="ellipsis" ml="1px" mb="2px" mono>
{cite(ship)}/{name}
</Text>
</>
);
}
function InviteStatus(props: { status?: JoinRequest }) {
const { status } = props;
if (!status) {
return null;
}
const current = status && joinProgress.indexOf(status.progress);
const desc = _.isNumber(current) && description[current];
return (
<Row gapX="1" alignItems="center" height={4}>
{ status.progress === 'done' ? <Icon icon="Checkmark" /> : <LoadingSpinner dark /> }
<Text gray>{desc}</Text>
</Row>
);
}
export function useInviteAccept(
resource: string,
api: GlobalApi,
app?: string,
uid?: string
) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState((s) => s.associations);
const groups = useGroupState((s) => s.groups);
const graphKeys = useGraphState((s) => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && uid)) {
return false;
}
if (resource in groups) {
await api.invite.decline(app, uid);
return false;
}
await api.groups.join(ship, name);
await api.invite.accept(app, uid);
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
const redir = inviteUrl(
groups?.[resource]?.hidden,
resource,
associations?.graph?.[resource]?.metadata
);
if (redir) {
// weird race condition
setTimeout(() => {
history.push(redir);
}, 200);
}
},
resource
);
}
function InviteActions(props: {
status?: JoinRequest;
resource: string;
api: GlobalApi;
app?: string;
uid?: string;
}) {
const { resource, api, app, uid } = props;
const inviteAccept = useInviteAccept(resource, api, app, uid);
const inviteDecline = useCallback(async () => {
if (!(app && uid)) {
return;
}
await api.invite.decline(app, uid);
}, [app, uid]);
const hideJoin = useCallback(async () => {
await api.groups.hide(resource);
}, [api, resource]);
const { status } = props;
if (status) {
if(status.progress === 'done') {
return null;
}
return (
<Row gapX="2" alignItems="center" height={4}>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={hideJoin}
>
Cancel
</StatelessAsyncButton>
</Row>
);
}
return (
<Row gapX="2" alignItems="center" height={4}>
<StatelessAsyncButton
color="blue"
height={4}
backgroundColor="white"
onClick={inviteAccept}
>
Accept
</StatelessAsyncButton>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={inviteDecline}
>
Decline
</StatelessAsyncButton>
</Row>
);
}
const responsiveStyle = ({ gapXY = 0 as number | number[] }) => {
return css({
flexDirection: ["column", "row"],
"& > *": {
marginTop: _.isArray(gapXY) ? [gapXY[0], 0] : [gapXY, 0],
marginLeft: _.isArray(gapXY) ? [0, ...gapXY.slice(1)] : [0, gapXY],
},
"& > :first-child": {
marginTop: 0,
marginLeft: 0,
},
});
};
const ResponsiveRow = styled(Row)(responsiveStyle);
export function GroupInvite(props: GroupInviteProps): ReactElement {
const { resource, api, preview, invite, status, app, uid } = props;
const dm = isDm(resource);
const history = useHistory();
const invitedTo = dm ? "DM" : "group";
const graphAssoc = useAssocForGraph(resource);
const headerProps = status
? { description: `You are joining a ${invitedTo}` }
: { description: `invited you to a ${invitedTo}`, authors: [invite!.ship] };
const onClick = () => {
if(status?.progress === 'done') {
const redir = inviteUrl(app !== 'groups', resource, graphAssoc?.metadata);
if(redir) {
history.push(redir);
}
}
}
return (
<NotificationWrapper api={api}>
<Header content {...headerProps} />
<Row onClick={onClick} height={[null, 4]} alignItems="flex-start" gridArea="main">
<Elbow mx="2" />
<ResponsiveRow
gapXY={[1, 2]}
height={[null, 4]}
alignItems={["flex-start", "center"]}
>
<InviteMetadata preview={preview} resource={resource} />
<InviteStatus status={status} />
<InviteActions
api={api}
resource={resource}
status={status}
app={app}
uid={uid}
/>
</ResponsiveRow>
</Row>
</NotificationWrapper>
);
}

View File

@ -7,113 +7,26 @@ import {
JoinRequests,
Groups,
Associations,
JoinRequest,
} from "@urbit/api";
import { Invite } from "@urbit/api/invite";
import { Text, Icon, Row } from "@tlon/indigo-react";
import { cite, useShowNickname } from "~/logic/lib/util";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
import { GroupInvite } from "./Group";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import useGroupState from "~/logic/state/group";
import useContactState from "~/logic/state/contact";
import useMetadataState from "~/logic/state/metadata";
import useGraphState from "~/logic/state/graph";
import { useRunIO } from "~/logic/lib/useRunIO";
interface InviteItemProps {
invite?: Invite;
resource: string;
pendingJoin?: string;
pendingJoin?: JoinRequest;
app?: string;
uid?: string;
api: GlobalApi;
}
export function useInviteAccept(
resource: string,
api: GlobalApi,
app?: string,
uid?: string,
invite?: Invite,
) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState((s) => s.associations);
const groups = useGroupState((s) => s.groups);
const graphKeys = useGraphState((s) => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && invite && uid)) {
return false;
}
if (resource in groups) {
await api.invite.decline(app, uid);
return false;
}
await api.groups.join(ship, name);
await api.invite.accept(app, uid);
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
if (groups?.[resource]?.hidden) {
const { metadata } = associations.graph[resource];
if (metadata && "graph" in metadata.config) {
if (metadata.config.graph === "chat") {
history.push(
`/~landscape/messages/resource/${metadata.config.graph}${resource}`
);
} else {
history.push(
`/~landscape/home/resource/${metadata.config.graph}${resource}`
);
}
} else {
console.error("unknown metadata: ", metadata);
}
} else {
history.push(`/~landscape${resource}`);
}
},
resource
);
}
export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { pendingJoin, invite, resource, uid, app, api } = props;
const { name } = resourceFromPath(resource);
const contacts = useContactState((state) => state.contacts);
const contact = contacts?.[`~${invite?.ship}`] ?? {};
const showNickname = useShowNickname(contact);
const inviteAccept = useInviteAccept(resource, api, app, uid, invite);
const inviteDecline = useCallback(async () => {
if (!(app && uid)) {
return;
}
await api.invite.decline(app, uid);
}, [app, uid]);
const handlers = { onAccept: inviteAccept, onDecline: inviteDecline };
useEffect(() => {
if (!app || app === "groups") {
@ -132,86 +45,17 @@ export function InviteItem(props: InviteItemProps) {
return null;
}
if (preview) {
return (
<GroupInvite
resource={resource}
api={api}
preview={preview}
invite={invite}
status={pendingJoin}
{...handlers}
/>
);
} else if (invite && name.startsWith("dm--")) {
return (
<InviteSkeleton
gapY="3"
{...handlers}
acceptDesc="Join DM"
declineDesc="Decline DM"
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text
mr="1"
mono={!showNickname}
fontWeight={showNickname ? "500" : "400"}
>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">invited you to a DM</Text>
</Row>
</InviteSkeleton>
);
} else if (status && name.startsWith("dm--")) {
return (
<JoinSkeleton api={api} resource={resource} status={status} gapY="3">
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">Joining direct message...</Text>
</Row>
</JoinSkeleton>
);
} else if (invite) {
return (
<InviteSkeleton
acceptDesc="Accept Invite"
declineDesc="Decline Invite"
resource={resource}
{...handlers}
gapY="3"
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text
mr="1"
mono={!showNickname}
fontWeight={showNickname ? "500" : "400"}
>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">
invited you to ~{invite.resource.ship}/{invite.resource.name}
</Text>
</Row>
</InviteSkeleton>
);
} else if (pendingJoin) {
const [, , ship, name] = resource.split("/");
return (
<JoinSkeleton api={api} resource={resource} status={pendingJoin}>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">You are joining</Text>
<Text mono>
{cite(ship)}/{name}
</Text>
</Row>
</JoinSkeleton>
);
return (
<GroupInvite
resource={resource}
api={api}
preview={preview}
invite={invite}
status={pendingJoin}
uid={uid}
app={app}
/>
);
}
return null;
}
export default InviteItem;

View File

@ -3,7 +3,7 @@ import _ from 'lodash';
import { Text, Box } from '@tlon/indigo-react';
import { Contact, Contacts, Content, Group } from '@urbit/api';
import RichText from '~/views/components/RichText';
import { cite, useShowNickname, uxToHex } from '~/logic/lib/util';
import { cite, useShowNickname, uxToHex, deSig } from '~/logic/lib/util';
import ProfileOverlay from '~/views/components/ProfileOverlay';
import { useHistory } from 'react-router-dom';
import useContactState, {useContact} from '~/logic/state/contact';
@ -45,7 +45,7 @@ export function Mention(props: {
api: any;
}) {
const { ship, first, api, ...rest } = props;
const contact = useContact(ship);
const contact = useContact(`~${deSig(ship)}`);
const showNickname = useShowNickname(contact);
const name = showNickname ? contact?.nickname : cite(ship);

View File

@ -6,9 +6,7 @@ import EmbedContainer from 'react-oembed-container';
import useSettingsState from '~/logic/state/settings';
import { RemoteContentPolicy } from '~/types/local-update';
import { VirtualContextProps, withVirtual } from "~/logic/lib/virtualContext";
import { IS_IOS } from '~/logic/lib/platform';
import withState from '~/logic/lib/withState';
import {Link} from 'react-router-dom';
type RemoteContentProps = VirtualContextProps & {
url: string;
@ -130,33 +128,41 @@ return;
});
}
wrapInLink(contents, textOnly = false) {
wrapInLink(contents, textOnly = false, unfold = false, unfoldEmbed = null, embedContainer = null) {
const { style } = this.props;
return (
<Box borderRadius="1" backgroundColor="washedGray" maxWidth="min(100%, 20rem)">
<Row
alignItems="center"
maxWidth="min(100%, 20rem)"
gapX="1" borderRadius="1" backgroundColor="washedGray">
gapX="1">
{ textOnly && (<Icon ml="2" display="block" icon="ArrowExternal" />)}
{ !textOnly && unfoldEmbed && (
<Icon
ml='2'
display='block'
onClick={unfoldEmbed}
icon={unfold ? 'TriangleSouth' : 'TriangleEast'}/>
)}
<BaseAnchor
display="flex"
p="2"
onClick={(e) => { e.stopPropagation(); }}
href={this.props.url}
flexShrink={0}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
minWidth="0"
width={textOnly ? "calc(100% - 24px)" : "fit-content"}
maxWidth="min(500px, 100%)"
style={{ color: 'inherit', textDecoration: 'none', ...style }}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => { e.stopPropagation(); }}
href={this.props.url}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
minWidth="0"
width={textOnly ? "calc(100% - 24px)" : "fit-content"}
maxWidth="min(500px, 100%)"
style={{ color: 'inherit', textDecoration: 'none', ...style }}
target="_blank"
rel="noopener noreferrer"
>
{contents}
</BaseAnchor>
</Row>
{embedContainer}
</Box>
);
}
@ -170,7 +176,6 @@ return;
remoteContentPolicy,
url,
text,
unfold = false,
renderUrl = true,
imageProps = {},
audioProps = {},
@ -208,68 +213,58 @@ return;
return (
<>
{renderUrl
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>)
? this.wrapInLink(
<TruncatedText {...textProps}>{url}</TruncatedText>,
false,
this.state.unfold,
this.unfoldEmbed,
<audio
onClick={(e) => { e.stopPropagation(); }}
controls
className={this.state.unfold ? "db" : "dn"}
src={url}
style={style}
onLoad={onLoad}
objectFit="contain"
height="100%"
width="100%"
{...audioProps}
{...props}
/>)
: null}
<audio
onClick={(e) => { e.stopPropagation(); }}
controls
className="db"
src={url}
style={style}
onLoad={onLoad}
objectFit="contain"
height="100%"
width="100%"
{...audioProps}
{...props}
/>
</>
);
} else if (isVideo && remoteContentPolicy.videoShown) {
return (
<>
{renderUrl
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>)
? this.wrapInLink(
<TruncatedText {...textProps}>{url}</TruncatedText>,
false,
this.state.unfold,
this.unfoldEmbed,
<video
onClick={(e) => { e.stopPropagation(); }}
controls
className={this.state.unfold ? 'db' : 'dn pa2'}
src={url}
style={style}
onLoad={onLoad}
objectFit="contain"
height="100%"
width="100%"
{...videoProps}
{...props}
/>)
: null}
<video
onClick={(e) => { e.stopPropagation(); }}
controls
className="db"
src={url}
style={style}
onLoad={onLoad}
objectFit="contain"
height="100%"
width="100%"
{...videoProps}
{...props}
/>
</>
);
} else if (isOembed && remoteContentPolicy.oembedShown) {
if (!this.state.embed || this.state.embed?.html === '') {
this.loadOembed();
}
return (
<Fragment>
{renderUrl
? this.wrapInLink(<TruncatedText {...textProps}>{(this.state.embed && this.state.embed.title)
? this.state.embed.title
: (text || url)}</TruncatedText>, true)
: null}
{this.state.embed !== 'error' && this.state.embed?.html && !unfold ? <Button
display='inline-flex'
border={1}
height={3}
ml={1}
onClick={this.unfoldEmbed}
flexShrink={0}
style={{ cursor: 'pointer' }}
>
{this.state.unfold ? 'collapse' : 'expand'}
</Button> : null}
<Box
const renderEmbed = !(this.state.embed !== 'error' && this.state.embed?.html);
const embed = <Box
mb='2'
width='100%'
flexShrink={0}
@ -281,17 +276,33 @@ return;
{...oembedProps}
{...props}
>
{this.state.embed && this.state.embed.html && this.state.unfold
? <EmbedContainer markup={this.state.embed.html}>
<div className="embed-container" ref={(el) => {
this.onLoad();
this.containerRef = el;
}}
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
></div>
</EmbedContainer>
: null}
</Box>
<TruncatedText
display={(renderUrl && this.state.embed?.title && this.state.embed.title !== url) ? 'inline-block' : 'none'}
fontWeight='bold' width='100%'>
{this.state.embed?.title}
</TruncatedText>
{this.state.embed && this.state.embed.html && this.state.unfold
? <EmbedContainer markup={this.state.embed.html}>
<div className="embed-container" ref={(el) => {
this.onLoad();
this.containerRef = el;
}}
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
></div>
</EmbedContainer>
: null}
</Box>;
return (
<Fragment>
{renderUrl
? this.wrapInLink(
<TruncatedText {...textProps}>{url}</TruncatedText>,
renderEmbed,
this.state.unfold,
this.unfoldEmbed,
embed
) : embed}
</Fragment>
);
} else {

View File

@ -23,6 +23,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
relative,
dateNotRelative,
fontSize,
lineHeight,
...rest
} = {
time: true,
@ -62,7 +63,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
title={stamp.format(DateFormat + ' ' + TimeFormat)}
>
{time && (
<Text flexShrink={0} color={color} fontSize={fontSize}>
<Text lineHeight={lineHeight} flexShrink={0} color={color} fontSize={fontSize}>
{timestamp}
</Text>
)}
@ -70,6 +71,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
<Text
flexShrink={0}
color={color}
lineHeight={lineHeight}
fontSize={fontSize}
display={time ? ['none', hovering ? 'block' : 'none'] : 'block'}
>

View File

@ -1,11 +1,16 @@
import React, { useMemo, useRef, useCallback, useEffect, useState } from 'react';
import React, {
useMemo,
useRef,
useCallback,
useEffect,
useState
} from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import * as ob from 'urbit-ob';
import Mousetrap from 'mousetrap';
import { omit } from 'lodash';
import { Box, Row, Text } from '@tlon/indigo-react';
import { Associations, Contacts, Groups, Invites } from '@urbit/api';
import makeIndex from '~/logic/lib/omnibox';
import OmniboxInput from './OmniboxInput';
import OmniboxResult from './OmniboxResult';
@ -13,10 +18,9 @@ import { deSig } from '~/logic/lib/util';
import { withLocalState } from '~/logic/state/local';
import defaultApps from '~/logic/lib/default-apps';
import {useOutsideClick} from '~/logic/lib/useOutsideClick';
import {Portal} from '../Portal';
import useSettingsState, {SettingsState} from '~/logic/state/settings';
import { Tile } from '~/types';
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
import { Portal } from '../Portal';
import useSettingsState, { SettingsState } from '~/logic/state/settings';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
@ -30,14 +34,21 @@ interface OmniboxProps {
notifications: number;
}
const SEARCHED_CATEGORIES = ['commands', 'ships', 'other', 'groups', 'subscriptions', 'apps'];
const SEARCHED_CATEGORIES = [
'commands',
'ships',
'other',
'groups',
'subscriptions',
'apps'
];
const settingsSel = (s: SettingsState) => s.leap;
export function Omnibox(props: OmniboxProps) {
export function Omnibox(props: OmniboxProps): ReactElement {
const location = useLocation();
const history = useHistory();
const leapConfig = useSettingsState(settingsSel);
const omniboxRef = useRef<HTMLDivElement | null>(null)
const omniboxRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
@ -46,21 +57,24 @@ export function Omnibox(props: OmniboxProps) {
const notifications = useHarkState(state => state.notifications);
const invites = useInviteState(state => state.invites);
const tiles = useLaunchState(state => state.tiles);
const [leapCursor, setLeapCursor] = useState('pointer');
const contacts = useMemo(() => {
const maybeShip = `~${deSig(query)}`;
return ob.isValidPatp(maybeShip)
? { ...contactState, [maybeShip]: {} }
: contactState;
const selflessContactState = omit(contactState, `~${window.ship}`);
return ob.isValidPatp(maybeShip) && maybeShip !== `~${window.ship}`
? { ...selflessContactState, [maybeShip]: {} }
: selflessContactState;
}, [contactState, query]);
const groups = useGroupState(state => state.groups);
const associations = useMetadataState(state => state.associations);
const selectedGroup = useMemo(
() => location.pathname.startsWith('/~landscape/ship/')
? '/' + location.pathname.split('/').slice(2,5).join('/')
: null,
const selectedGroup = useMemo(
() =>
location.pathname.startsWith('/~landscape/ship/')
? '/' + location.pathname.split('/').slice(2, 5).join('/')
: null,
[location.pathname]
);
@ -71,16 +85,9 @@ export function Omnibox(props: OmniboxProps) {
tiles,
selectedGroup,
groups,
leapConfig,
leapConfig
);
}, [
selectedGroup,
leapConfig,
contacts,
associations,
groups,
tiles
]);
}, [selectedGroup, leapConfig, contacts, associations, groups, tiles]);
const onOutsideClick = useCallback(() => {
props.show && props.toggle();
@ -90,7 +97,7 @@ export function Omnibox(props: OmniboxProps) {
// handle omnibox show
useEffect(() => {
if(!props.show) {
if (!props.show) {
return;
}
Mousetrap.bind('escape', props.toggle);
@ -104,29 +111,37 @@ export function Omnibox(props: OmniboxProps) {
}, [props.show]);
const initialResults = useMemo(() => {
return new Map(SEARCHED_CATEGORIES.map((category) => {
if (category === 'other') {
return ['other', index.get('other').filter(({ app }) => app !== 'tutorial')];
}
return [category, []];
}));
return new Map(
SEARCHED_CATEGORIES.map((category) => {
if (category === 'other') {
return [
'other',
index.get('other').filter(({ app }) => app !== 'tutorial')
];
}
return [category, []];
})
);
}, [index]);
const results = useMemo(() => {
if(query.length <= 1) {
if (query.length <= 1) {
return initialResults;
}
const q = query.toLowerCase();
const resultsMap = new Map();
SEARCHED_CATEGORIES.map((category) => {
const categoryIndex = index.get(category);
resultsMap.set(category,
resultsMap.set(
category,
categoryIndex.filter((result) => {
return (
result.title.toLowerCase().includes(q) ||
result.link.toLowerCase().includes(q) ||
result.app.toLowerCase().includes(q) ||
(result.host !== null ? result.host.toLowerCase().includes(q) : false)
(result.host !== null
? result.host.toLowerCase().includes(q)
: false)
);
})
);
@ -134,21 +149,26 @@ export function Omnibox(props: OmniboxProps) {
return resultsMap;
}, [query, index]);
const navigate = useCallback((app: string, link: string) => {
props.toggle();
if (defaultApps.includes(app.toLowerCase())
|| app === 'profile'
|| app === 'messages'
|| app === 'tutorial'
|| app === 'Links'
|| app === 'Terminal'
|| app === 'home'
|| app === 'inbox') {
history.push(link);
} else {
window.location.href = link;
}
}, [history, props.toggle]);
const navigate = useCallback(
(app: string, link: string) => {
props.toggle();
if (
defaultApps.includes(app.toLowerCase()) ||
app === 'profile' ||
app === 'messages' ||
app === 'tutorial' ||
app === 'Links' ||
app === 'Terminal' ||
app === 'home' ||
app === 'inbox'
) {
history.push(link);
} else {
window.location.href = link;
}
},
[history, props.toggle]
);
const setPreviousSelected = useCallback(() => {
const flattenedResults = Array.from(results.values()).flat();
@ -193,50 +213,59 @@ export function Omnibox(props: OmniboxProps) {
}
}, [selected, results]);
const control = useCallback((evt) => {
if (evt.key === 'Escape') {
if (query.length > 0) {
setQuery('');
return;
} else if (props.show) {
props.toggle();
return;
const setSelection = (app, link) => {
setLeapCursor('pointer');
setSelected([app, link]);
};
const control = useCallback(
(evt) => {
if (evt.key === 'Escape') {
if (query.length > 0) {
setQuery('');
return;
} else if (props.show) {
props.toggle();
return;
}
}
}
if (
evt.key === 'ArrowUp' ||
(evt.shiftKey && evt.key === 'Tab')) {
if (evt.key === 'ArrowUp' || (evt.shiftKey && evt.key === 'Tab')) {
evt.preventDefault();
setPreviousSelected();
return;
}
if (evt.key === 'ArrowDown' || evt.key === 'Tab') {
evt.preventDefault();
setNextSelected();
return;
}
if (evt.key === 'Enter') {
evt.preventDefault();
if (selected.length) {
navigate(selected[0], selected[1]);
} else if (Array.from(results.values()).flat().length === 0) {
setPreviousSelected();
setLeapCursor('none');
return;
} else {
navigate(
Array.from(results.values()).flat()[0].app,
Array.from(results.values()).flat()[0].link);
}
}
}, [
props.toggle,
selected,
navigate,
query,
props.show,
results,
setPreviousSelected,
setNextSelected
]);
if (evt.key === 'ArrowDown' || evt.key === 'Tab') {
evt.preventDefault();
setNextSelected();
setLeapCursor('none');
return;
}
if (evt.key === 'Enter') {
evt.preventDefault();
if (selected.length) {
navigate(selected[0], selected[1]);
} else if (Array.from(results.values()).flat().length === 0) {
return;
} else {
navigate(
Array.from(results.values()).flat()[0].app,
Array.from(results.values()).flat()[0].link
);
}
}
},
[
props.toggle,
selected,
navigate,
query,
props.show,
results,
setPreviousSelected,
setNextSelected
]
);
useEffect(() => {
const flattenedResultLinks = Array.from(results.values())
@ -252,88 +281,106 @@ export function Omnibox(props: OmniboxProps) {
}, []);
// Sort Omnibox results alphabetically
const sortResults = (a: Record<'title', string>, b: Record<'title', string>) => {
const sortResults = (
a: Record<'title', string>,
b: Record<'title', string>
) => {
// Do not sort unless searching (preserves order of menu actions)
if (query === '') { return 0 };
if (a.title < b.title) { return -1 };
if (a.title > b.title) { return 1 };
if (query === '') {
return 0;
}
if (a.title < b.title) {
return -1;
}
if (a.title > b.title) {
return 1;
}
return 0;
}
};
const renderResults = useCallback(() => {
return <Box
maxHeight={['200px', '400px']}
overflowY="auto"
overflowX="hidden"
borderBottomLeftRadius='2'
borderBottomRightRadius='2'
>
{SEARCHED_CATEGORIES
.map(category => Object({ category, categoryResults: results.get(category) }))
.filter(category => category.categoryResults.length > 0)
.map(({ category, categoryResults }, i) => {
const categoryTitle = (category === 'other')
? null : <Row pl='2' height='5' alignItems='center' bg='washedGray'><Text gray bold>{category.charAt(0).toUpperCase() + category.slice(1)}</Text></Row>;
const sel = selected?.length ? selected[1] : '';
return (<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
{categoryTitle}
{categoryResults
.sort(sortResults)
.map((result, i2) => (
<OmniboxResult
key={i2}
icon={result.app}
text={result.title}
subtext={result.host}
link={result.link}
navigate={() => navigate(result.app, result.link)}
selected={sel}
/>
))}
</Box>
);
})
}
</Box>;
return (
<Box
maxHeight={['200px', '400px']}
overflowY='auto'
overflowX='hidden'
borderBottomLeftRadius='2'
borderBottomRightRadius='2'
>
{SEARCHED_CATEGORIES.map(category =>
Object({ category, categoryResults: results.get(category) })
)
.filter(category => category.categoryResults.length > 0)
.map(({ category, categoryResults }, i) => {
const categoryTitle =
category === 'other' ? null : (
<Row pl='2' height='5' alignItems='center' bg='washedGray'>
<Text gray bold>
{category.charAt(0).toUpperCase() + category.slice(1)}
</Text>
</Row>
);
const sel = selected?.length ? selected[1] : '';
return (
<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
{categoryTitle}
{categoryResults.sort(sortResults).map((result, i2) => (
<OmniboxResult
key={i2}
icon={result.app}
text={result.title}
subtext={result.host}
link={result.link}
cursor={leapCursor}
navigate={() => navigate(result.app, result.link)}
setSelection={() => setSelection(result.app, result.link)}
selected={sel}
/>
))}
</Box>
);
})}
</Box>
);
}, [results, navigate, selected, contactState, notifications, invites]);
return (
<Portal>
<Box
backgroundColor='scales.black30'
width='100%'
height='100%'
position='absolute'
top='0'
right='0'
zIndex={11}
display={props.show ? 'block' : 'none'}
>
<Row justifyContent='center'>
<Box
mt={['10vh', '20vh']}
width='max(50vw, 300px)'
maxWidth='600px'
borderRadius='2'
backgroundColor='white'
<Box
backgroundColor='scales.black30'
width='100%'
height='100%'
position='absolute'
top='0'
right='0'
zIndex={11}
display={props.show ? 'block' : 'none'}
>
<Row justifyContent='center'>
<Box
mt={['10vh', '20vh']}
width='max(50vw, 300px)'
maxWidth='600px'
borderRadius='2'
backgroundColor='white'
ref={(el) => {
omniboxRef.current = el;
}}
>
<OmniboxInput
ref={(el) => {
omniboxRef.current = el;
}}
>
<OmniboxInput
ref={(el) => {
inputRef.current = el;
}}
control={e => control(e)}
search={search}
query={query}
/>
{renderResults()}
</Box>
</Row>
</Box>
</Portal>
);
}
inputRef.current = el;
}}
control={e => control(e)}
search={search}
query={query}
/>
{renderResults()}
</Box>
</Row>
</Box>
</Portal>
);
}
export default withLocalState(Omnibox, ['toggleOmnibox', 'omniboxShown']);

View File

@ -1,4 +1,3 @@
import React, { Component } from 'react';
import { BaseInput } from '@tlon/indigo-react';
@ -6,33 +5,31 @@ export class OmniboxInput extends Component {
render() {
const { props } = this;
return (
<BaseInput
ref={(el) => {
this.input = el;
<BaseInput
ref={(el) => {
this.input = el;
if (el && document.activeElement.isSameNode(el)) {
el.blur();
el.focus();
}
}
}
width='100%'
p='2'
backgroundColor='white'
color='black'
border='1px solid transparent'
borderRadius='2'
maxWidth='calc(600px - 1.15rem)'
fontSize='1'
style={{ boxSizing: 'border-box' }}
placeholder='Search...'
onKeyDown={props.control}
onChange={props.search}
spellCheck={false}
value={props.query}
/>
}}
width='100%'
p='2'
backgroundColor='white'
color='black'
border='1px solid transparent'
borderRadius='2'
maxWidth='calc(600px - 1.15rem)'
fontSize='1'
style={{ boxSizing: 'border-box' }}
placeholder='Search...'
onKeyDown={props.control}
onChange={props.search}
spellCheck={false}
value={props.query}
/>
);
}
}
export default OmniboxInput;

View File

@ -21,52 +21,143 @@ export class OmniboxResult extends Component {
componentDidUpdate(prevProps) {
const { props, state } = this;
if (prevProps &&
if (
prevProps &&
!state.hovered &&
prevProps.selected !== props.selected &&
props.selected === props.link
) {
this.result.current.scrollIntoView({ block: 'nearest' });
}
) {
this.result.current.scrollIntoView({ block: 'nearest' });
}
}
getIcon(icon, selected, link, invites, notifications, text, color) {
const iconFill = (this.state.hovered || (selected === link)) ? 'white' : 'black';
const bulletFill = (this.state.hovered || (selected === link)) ? 'white' : 'blue';
const iconFill =
this.state.hovered || selected === link ? 'white' : 'black';
const bulletFill =
this.state.hovered || selected === link ? 'white' : 'blue';
const inviteCount = [].concat(...Object.values(invites).map(obj => Object.values(obj)));
const inviteCount = [].concat(
...Object.values(invites).map((obj) => Object.values(obj))
);
let graphic = <div />;
if (defaultApps.includes(icon.toLowerCase())
|| icon.toLowerCase() === 'links'
|| icon.toLowerCase() === 'terminal')
{
icon = (icon === 'Link') ? 'Collection' :
(icon === 'Terminal') ? 'Dojo' : icon;
graphic = <Icon display="inline-block" verticalAlign="middle" icon={icon} mr='2' size='18px' color={iconFill} />;
if (
defaultApps.includes(icon.toLowerCase()) ||
icon.toLowerCase() === 'links' ||
icon.toLowerCase() === 'terminal'
) {
icon =
icon === 'Link' ? 'Collection' : icon === 'Terminal' ? 'Dojo' : icon;
graphic = (
<Icon
display='inline-block'
verticalAlign='middle'
icon={icon}
mr='2'
size='18px'
color={iconFill}
/>
);
} else if (icon === 'inbox') {
graphic = <Box display='flex' verticalAlign='middle' position="relative">
<Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />
{(notifications > 0 || inviteCount.length > 0) && (
<Icon display='inline-block' icon='Bullet' style={{ position: 'absolute', top: -5, left: 5 }} color={bulletFill} />
)}
</Box>;
graphic = (
<Box display='flex' verticalAlign='middle' position='relative'>
<Icon
display='inline-block'
verticalAlign='middle'
icon='Inbox'
mr='2'
size='18px'
color={iconFill}
/>
{(notifications > 0 || inviteCount.length > 0) && (
<Icon
display='inline-block'
icon='Bullet'
style={{ position: 'absolute', top: -5, left: 5 }}
color={bulletFill}
/>
)}
</Box>
);
} else if (icon === 'logout') {
graphic = <Icon display="inline-block" verticalAlign="middle" icon='SignOut' mr='2' size='18px' color={iconFill} />;
graphic = (
<Icon
display='inline-block'
verticalAlign='middle'
icon='SignOut'
mr='2'
size='18px'
color={iconFill}
/>
);
} else if (icon === 'profile') {
text = text.startsWith('Profile') ? window.ship : text;
graphic = <Sigil color={color} classes='dib flex-shrink-0 v-mid mr2' ship={text} size={18} icon padding={2} />;
graphic = (
<Sigil
color={color}
classes='dib flex-shrink-0 v-mid mr2'
ship={text}
size={18}
icon
padding={2}
/>
);
} else if (icon === 'home') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Home' mr='2' size='18px' color={iconFill} />;
graphic = (
<Icon
display='inline-block'
verticalAlign='middle'
icon='Home'
mr='2'
size='18px'
color={iconFill}
/>
);
} else if (icon === 'notifications') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />;
graphic = (
<Icon
display='inline-block'
verticalAlign='middle'
icon='Inbox'
mr='2'
size='18px'
color={iconFill}
/>
);
} else if (icon === 'messages') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Users' mr='2' size='18px' color={iconFill} />;
graphic = (
<Icon
display='inline-block'
verticalAlign='middle'
icon='Users'
mr='2'
size='18px'
color={iconFill}
/>
);
} else if (icon === 'tutorial') {
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Tutorial' mr='2' size='18px' color={iconFill} />;
}
else {
graphic = <Icon display='inline-block' icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
graphic = (
<Icon
display='inline-block'
verticalAlign='middle'
icon='Tutorial'
mr='2'
size='18px'
color={iconFill}
/>
);
} else {
graphic = (
<Icon
display='inline-block'
icon='NullIcon'
verticalAlign='middle'
mr='2'
size='16px'
color={iconFill}
/>
);
}
return graphic;
@ -77,53 +168,81 @@ export class OmniboxResult extends Component {
}
render() {
const { icon, text, subtext, link, navigate, selected, invites, notificationsCount, contacts } = this.props;
const {
icon,
text,
subtext,
link,
cursor,
navigate,
selected,
invites,
notificationsCount,
contacts,
setSelection
} = this.props;
const color = contacts?.[text] ? `#${uxToHex(contacts[text].color)}` : "#000000";
const graphic = this.getIcon(icon, selected, link, invites, notificationsCount, text, color);
const color = contacts?.[text]
? `#${uxToHex(contacts[text].color)}`
: '#000000';
const graphic = this.getIcon(
icon,
selected,
link,
invites,
notificationsCount,
text,
color
);
return (
<Row
py='2'
px='2'
cursor='pointer'
onMouseEnter={() => this.setHover(true)}
onMouseLeave={() => this.setHover(false)}
backgroundColor={
this.state.hovered || selected === link ? 'blue' : 'white'
}
onClick={navigate}
width="100%"
justifyContent="space-between"
ref={this.result}
py='2'
px='2'
cursor={cursor}
onMouseMove={() => setSelection()}
onMouseLeave={() => this.setHover(false)}
backgroundColor={
this.state.hovered || selected === link ? 'blue' : 'white'
}
onClick={navigate}
width='100%'
justifyContent='space-between'
ref={this.result}
>
<Box display="flex" verticalAlign="middle" maxWidth="60%" flexShrink={0}>
{graphic}
<Text
mono={(icon == 'profile' && text.startsWith('~'))}
color={this.state.hovered || selected === link ? 'white' : 'black'}
display='inline-block'
verticalAlign='middle'
width='100%'
overflow='hidden'
textOverflow='ellipsis'
whiteSpace='pre'
mr='1'
<Box
display='flex'
verticalAlign='middle'
maxWidth='60%'
flexShrink={0}
>
{text.startsWith("~") ? cite(text) : text}
</Text>
{graphic}
<Text
mono={icon == 'profile' && text.startsWith('~')}
color={this.state.hovered || selected === link ? 'white' : 'black'}
display='inline-block'
verticalAlign='middle'
width='100%'
overflow='hidden'
textOverflow='ellipsis'
whiteSpace='pre'
mr='1'
>
{text.startsWith('~') ? cite(text) : text}
</Text>
</Box>
<Text pr='2'
display="inline-block"
verticalAlign="middle"
color={this.state.hovered || selected === link ? 'white' : 'black'}
width='100%'
minWidth={0}
textOverflow="ellipsis"
whiteSpace="pre"
overflow="hidden"
maxWidth="40%"
textAlign='right'
<Text
pr='2'
display='inline-block'
verticalAlign='middle'
color={this.state.hovered || selected === link ? 'white' : 'black'}
width='100%'
minWidth={0}
textOverflow='ellipsis'
whiteSpace='pre'
overflow='hidden'
maxWidth='40%'
textAlign='right'
>
{subtext}
</Text>
@ -136,4 +255,4 @@ export default withState(OmniboxResult, [
[useInviteState],
[useHarkState, ['notificationsCount']],
[useContactState]
]);
]);

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ export function ChannelPopoverRoutesSidebar(props: {
return (
<Col
display={['none', 'flex-column']}
display={['none', 'flex']}
minWidth="200px"
borderRight="1"
borderRightColor="washedGray"

View File

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

View File

@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
import urbitOb from 'urbit-ob';
import { Text } from '@tlon/indigo-react';
import { Text, Anchor } from '@tlon/indigo-react';
import { GroupLink } from '~/views/components/GroupLink';
import { Row } from '@tlon/indigo-react';
@ -22,7 +22,6 @@ const DISABLED_INLINE_TOKENS = [
'autoLink',
'url',
'email',
'link',
'reference'
];
@ -75,6 +74,9 @@ const renderers = {
{value}
</Text>
);
},
link: (props) => {
return <Anchor src={props.href} borderBottom="1" color="black">{props.children}</Anchor>
}
};

View File

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

View File

@ -81,6 +81,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
if(group === TUTORIAL_GROUP_RESOURCE) {
await api.settings.putEntry('tutorial', 'joined', Date.now());
}
if (group in groups) {
return history.push(`/~landscape${group}`);
}
await api.groups.join(ship, name);
try {
await waiter((p) => {
@ -111,6 +114,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
const [ship, name] = values.group.split('/');
const path = `/ship/${ship}/${name}`;
if (path in groups) {
return history.push(`/~landscape${path}`);
}
// skip if it's unmanaged
try {
const prev = await api.metadata.preview(path);

View File

@ -295,7 +295,7 @@ function Participant(props: {
const resource = resourceFromPath(association.group);
if(contact.pending) {
await api.groups.changePolicy(
resource,
resource,
{ invite: { removeInvites: [`~${contact.patp}`] } }
);
} else {
@ -305,12 +305,12 @@ function Participant(props: {
const avatar =
contact?.avatar !== null && !hideAvatars ? (
<Image
src={contact.avatar}
height={32}
width={32}
<Image
src={contact.avatar}
height={32}
width={32}
display='inline-block'
style={{ objectFit: 'cover' }}
style={{ objectFit: 'cover' }}
/>
) : (
<Sigil ship={contact.patp} size={32} color={`#${color}`} />
@ -386,9 +386,9 @@ function Participant(props: {
{(contact.patp !== window.ship) && (<StatelessAsyncAction onClick={onKick} bg="transparent">
<Text color="red">Kick from {title}</Text>
</StatelessAsyncAction>)}
<StatelessAsyncAction onClick={onPromote} bg="transparent">
{!contact.pending && <StatelessAsyncAction onClick={onPromote} bg="transparent">
Promote to Admin
</StatelessAsyncAction>
</StatelessAsyncAction>}
</>
)}
</>

View File

@ -18,7 +18,11 @@ export function useGraphModule(
}
const notifications = graphUnreads?.[s]?.['/']?.notifications;
if ( notifications > 0 ) {
if (
notifications &&
((typeof notifications === 'number' && notifications > 0)
|| notifications.length)
) {
return 'notification';
}

View File

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

1
pkg/npm/.gitignore vendored
View File

@ -1 +0,0 @@
package-lock.json

46
pkg/npm/api/package-lock.json generated Normal file
View File

@ -0,0 +1,46 @@
{
"name": "@urbit/api",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/runtime": {
"version": "7.13.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.7.tgz",
"integrity": "sha512-h+ilqoX998mRVM5FtB5ijRuHUDVt5l3yfoOi2uh18Z/O3hvyaHQ39NpxVkCIG5yFs+mLq/ewFp8Bss6zmWv6ZA==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@types/lodash": {
"version": "4.14.168",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q=="
},
"@urbit/eslint-config": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.0.tgz",
"integrity": "sha512-Xmzb6MvM7KorlPJEq/hURZZ4BHSVy/7CoQXWogsBSTv5MOZnMqwNKw6yt24k2AO/2UpHwjGptimaNLqFfesJbw=="
},
"big-integer": {
"version": "1.6.48",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
"integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w=="
},
"immer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.1.tgz",
"integrity": "sha512-7CCw1DSgr8kKYXTYOI1qMM/f5qxT5vIVMeGLDCDX8CSxsggr1Sjdoha4OhsP0AZ1UvWbyZlILHvLjaynuu02Mg=="
},
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
}

1080
pkg/npm/eslint-config/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

6067
pkg/npm/http-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@
u3_csat_init = 0, // initialized
u3_csat_addr = 1, // address resolution begun
u3_csat_quit = 2, // cancellation requested
u3_csat_ripe = 3 // passed to libh2o
u3_csat_conn = 3, // sync connect phase
u3_csat_ripe = 4 // passed to libh2o
} u3_csat;
/* u3_cres: response to http client.
@ -27,26 +28,26 @@
/* u3_creq: outgoing http request.
*/
typedef struct _u3_creq { // client request
c3_l num_l; // request number
c3_l num_l; // request number
h2o_http1client_t* cli_u; // h2o client
u3_csat sat_e; // connection state
c3_o sec; // yes == https
c3_w ipf_w; // IP
c3_c* ipf_c; // IP (string)
c3_c* hot_c; // host
c3_s por_s; // port
c3_c* por_c; // port (string)
c3_c* met_c; // method
c3_c* url_c; // url
u3_hhed* hed_u; // headers
u3_hbod* bod_u; // body
u3_hbod* rub_u; // exit of send queue
u3_hbod* bur_u; // entry of send queue
h2o_iovec_t* vec_u; // send-buffer array
u3_cres* res_u; // nascent response
struct _u3_creq* nex_u; // next in list
struct _u3_creq* pre_u; // previous in list
struct _u3_cttp* ctp_u; // cttp backpointer
u3_csat sat_e; // connection state
c3_o sec; // yes == https
c3_w ipf_w; // IP
c3_c* ipf_c; // IP (string)
c3_c* hot_c; // host
c3_s por_s; // port
c3_c* por_c; // port (string)
c3_c* met_c; // method
c3_c* url_c; // url
u3_hhed* hed_u; // headers
u3_hbod* bod_u; // body
u3_hbod* rub_u; // exit of send queue
u3_hbod* bur_u; // entry of send queue
h2o_iovec_t* vec_u; // send-buffer array
u3_cres* res_u; // nascent response
struct _u3_creq* nex_u; // next in list
struct _u3_creq* pre_u; // previous in list
struct _u3_cttp* ctp_u; // cttp backpointer
} u3_creq;
/* u3_cttp: http client.
@ -819,7 +820,15 @@ _cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
u3_creq* ceq_u = (u3_creq *)cli_u->data;
if ( 0 != err_c ) {
_cttp_creq_fail(ceq_u, err_c);
// if synchronously connecting, caller will cleanup
//
if ( u3_csat_conn == ceq_u->sat_e ) {
ceq_u->sat_e = u3_csat_quit;
}
else {
c3_assert( u3_csat_ripe == ceq_u->sat_e );
_cttp_creq_fail(ceq_u, err_c);
}
return 0;
}
@ -844,17 +853,10 @@ _cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
static void
_cttp_creq_connect(u3_creq* ceq_u)
{
c3_assert( u3_csat_ripe == ceq_u->sat_e );
c3_assert( u3_csat_conn == ceq_u->sat_e );
c3_assert( ceq_u->ipf_c );
// set hostname for TLS handshake
//
if ( ceq_u->hot_c && c3y == ceq_u->sec ) {
c3_free(ceq_u->cli_u->ssl.server_name);
ceq_u->cli_u->ssl.server_name = strdup(ceq_u->hot_c);
}
// connect by IP
// connect by ip/port, avoiding synchronous getaddrinfo()
//
{
h2o_iovec_t ipf_u = h2o_iovec_init(ceq_u->ipf_c, strlen(ceq_u->ipf_c));
@ -866,6 +868,28 @@ _cttp_creq_connect(u3_creq* ceq_u)
h2o_http1client_connect(&ceq_u->cli_u, ceq_u, &ceq_u->ctp_u->ctx_u,
ipf_u, por_s, tls_t, _cttp_creq_on_connect);
}
// connect() failed, cb invoked synchronously
//
if ( u3_csat_conn != ceq_u->sat_e ) {
c3_assert( u3_csat_quit == ceq_u->sat_e );
// only one such failure case
//
_cttp_creq_fail(ceq_u, "socket create error");
}
else {
ceq_u->sat_e = u3_csat_ripe;
// fixup hostname for TLS handshake
//
// must be synchronous, after successfull connect() call
//
if ( ceq_u->hot_c && (c3y == ceq_u->sec) ) {
c3_assert( ceq_u->cli_u );
c3_free(ceq_u->cli_u->ssl.server_name);
ceq_u->cli_u->ssl.server_name = strdup(ceq_u->hot_c);
}
}
}
/* _cttp_creq_resolve_cb(): cb upon IP address resolution
@ -888,7 +912,7 @@ _cttp_creq_resolve_cb(uv_getaddrinfo_t* adr_u,
ceq_u->ipf_w = ntohl(((struct sockaddr_in *)aif_u->ai_addr)->sin_addr.s_addr);
ceq_u->ipf_c = _cttp_creq_ip(ceq_u->ipf_w);
ceq_u->sat_e = u3_csat_ripe;
ceq_u->sat_e = u3_csat_conn;
_cttp_creq_connect(ceq_u);
}
@ -932,7 +956,7 @@ static void
_cttp_creq_start(u3_creq* ceq_u)
{
if ( ceq_u->ipf_c ) {
ceq_u->sat_e = u3_csat_ripe;
ceq_u->sat_e = u3_csat_conn;
_cttp_creq_connect(ceq_u);
} else {
ceq_u->sat_e = u3_csat_addr;

View File

@ -1 +1 @@
1.4
1.5