Merge branch 'master' into la/ref-trans-graph

This commit is contained in:
Logan Allen 2021-04-28 12:53:06 -05:00
commit d5ed85efa8
101 changed files with 10045 additions and 1653 deletions

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:accbadc701471f6b071ed286164a0bf4d3f8a2e64cfaea9019e123bd9edca569 oid sha256:f6b5e33e573818120051651c1182163527edbbe0dff0eb6591e12a55cfccb273
size 10292454 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 let
startUrbit = writeScriptBin "start-urbit" '' startUrbit = writeScriptBin "start-urbit" ''
#!${bashInteractive}/bin/bash #!${bashInteractive}/bin/bash
set -eu 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 # If the container is not started with the `-i` flag
# then STDIN will be closed and we need to start # then STDIN will be closed and we need to start
# Urbit/vere with the `-t` flag. # Urbit/vere with the `-t` flag.
@ -23,7 +37,7 @@ let
mv $keyname /tmp mv $keyname /tmp
# Boot urbit with the key, exit when done booting # 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 # Remove the keyfile for security
rm /tmp/$keyname rm /tmp/$keyname
@ -34,7 +48,7 @@ let
cometname=''${comets[0]} cometname=''${comets[0]}
rm *.comet rm *.comet
urbit $ttyflag -c $(basename $cometname .comet) -p ${toString amesPort} -x urbit $ttyflag -c $(basename $cometname .comet) -p $amesPort -x
fi fi
# Find the first directory and start urbit with the ship therein # Find the first directory and start urbit with the ship therein
@ -42,14 +56,44 @@ let
dirs=( $dirnames ) dirs=( $dirnames )
dirname=''${dirnames[0]} 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 { in dockerTools.buildImage {
name = "urbit"; name = "urbit";
tag = "v${urbit.version}"; tag = "v${urbit.version}";
contents = [ bashInteractive urbit startUrbit coreutils ]; contents = [ bashInteractive urbit curl startUrbit getUrbitCode resetUrbitCode coreutils ];
runAsRoot = '' runAsRoot = ''
#!${bashInteractive} #!${bashInteractive}
mkdir -p /urbit mkdir -p /urbit

View File

@ -82,6 +82,8 @@ haskell-nix.stackProject {
urbit-king.components.tests.urbit-king-tests.testFlags = urbit-king.components.tests.urbit-king-tests.testFlags =
[ "--brass-pill=${brass.lfs}" ]; [ "--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)) !=(contact(last-updated *@da) u.old(last-updated *@da))
== ==
[~ state] [~ state]
~| "cannot add a data url to cover!"
?> ?| ?=(~ cover.contact)
!=('data:' (cut 3 [0 5] u.cover.contact))
==
~| "cannot add a data url to avatar!"
?> ?| ?=(~ avatar.contact)
!=('data:' (cut 3 [0 5] u.avatar.contact))
==
:- (send-diff [%add ship contact] =(ship our.bowl)) :- (send-diff [%add ship contact] =(ship our.bowl))
state(rolodex (~(put by rolodex) ship contact)) state(rolodex (~(put by rolodex) ship contact))
:: ::
@ -149,6 +157,14 @@
=/ contact (edit-contact old edit-field) =/ contact (edit-contact old edit-field)
?: =(old contact) ?: =(old contact)
[~ state] [~ state]
~| "cannot add a data url to cover!"
?> ?| ?=(~ cover.contact)
!=('data:' (cut 3 [0 5] u.cover.contact))
==
~| "cannot add a data url to avatar!"
?> ?| ?=(~ avatar.contact)
!=('data:' (cut 3 [0 5] u.avatar.contact))
==
=. last-updated.contact timestamp =. last-updated.contact timestamp
:- (send-diff [%edit ship edit-field timestamp] =(ship our.bowl)) :- (send-diff [%edit ship edit-field timestamp] =(ship our.bowl))
state(rolodex (~(put by rolodex) ship contact)) state(rolodex (~(put by rolodex) ship contact))

View File

@ -243,7 +243,7 @@
=/ headers =/ headers
:~ content-type+mime-type :~ content-type+mime-type
max-1-da:gen max-1-da:gen
'Service-Worker-Allowed'^'/' 'service-worker-allowed'^'/'
== ==
[[200 headers] `q.u.data] [[200 headers] `q.u.data]
== ==

View File

@ -5,7 +5,7 @@
/- glob /- glob
/+ default-agent, verb, dbug /+ default-agent, verb, dbug
|% |%
++ hash 0v4.7tk5q.9ha4l.tbmji.fvkno.s9pfq ++ hash 0v3.g6u13.haedt.jt4hd.61ek5.6t30q
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states +$ all-states
$% state-0 $% state-0

View File

@ -37,79 +37,13 @@
++ on-init on-init:def ++ on-init on-init:def
++ on-save !>(~) ++ on-save !>(~)
++ on-load on-load:def ++ on-load on-load:def
++ on-poke ++ on-poke on-poke:def
|= [=mark =vase] ++ on-agent on-agent:def
^- (quip card _this) ++ on-watch on-watch:def
|^ ++ on-leave on-leave:def
?. =(mark %sane) ++ on-peek on-peek:def
(on-poke:def mark vase) ++ on-arvo on-arvo:def
[(sane !<(?(%check %fix) vase)) this] ++ on-fail on-fail:def
::
++ scry-sharing
.^ (set resource)
%gx
(scot %p our.bowl)
%group-push-hook
(scot %da now.bowl)
/sharing/noun
==
::
++ sane
|= input=?(%check %fix)
^- (list card)
=; cards=(list card)
?: =(%check input)
~&(cards ~)
cards
%+ murn
~(tap in scry-sharing)
|= rid=resource
^- (unit card)
=/ u-g=(unit group)
(scry-group:grp rid)
?~ u-g
`(poke-us %remove rid)
=* group u.u-g
=/ subs=(set ship)
(get-subscribers-for-group rid)
=/ to-remove=(set ship)
(~(dif in members.group) (~(gas in subs) our.bowl ~))
?~ to-remove ~
`(poke-store %remove-members rid to-remove)
::
++ poke-us
|= =action:push-hook
^- card
=- [%pass / %agent [our.bowl %group-push-hook] %poke -]
push-hook-action+!>(action)
::
++ poke-store
|= =update:store
^- card
=+ group-update-0+!>(update)
[%pass /sane %agent [our.bowl %group-store] %poke -]
::
++ get-subscribers-for-group
|= rid=resource
^- (set ship)
=/ target=path
(en-path:resource rid)
%- ~(gas in *(set ship))
%+ murn
~(val by sup.bowl)
|= [her=ship =path]
^- (unit ship)
?. =(path resource+target)
~
`her
--
++ on-agent on-agent:def
++ on-watch on-watch:def
++ on-leave on-leave:def
++ on-peek on-peek:def
++ on-arvo on-arvo:def
++ on-fail on-fail:def
:: ::
++ transform-proxy-update ++ transform-proxy-update
|= vas=vase |= vas=vase

View File

@ -414,11 +414,8 @@
?> ?& ?=(~ (~(dif in ships) members)) ?> ?& ?=(~ (~(dif in ships) members))
(~(has by tags) tag) (~(has by tags) tag)
== ==
%= +< %= +<
:: tags (dif-ju tags tag ships)
tags
%+ ~(jab by tags) tag
|=((set ship) (~(dif in +<) ships))
== ==
:_ state :_ state
(send-diff %remove-tag rid tag ships) (send-diff %remove-tag rid tag ships)
@ -543,7 +540,15 @@
(send-diff %remove-group rid ~) (send-diff %remove-group rid ~)
:: ::
-- --
:: TODO: move to +zuse
++ dif-ju
|= [=tags =tag remove=(set ship)]
=/ ships ~(tap in remove)
|-
?~ ships
tags
$(tags (~(del ju tags) tag i.ships), ships t.ships)
::
++ merge-tags ++ merge-tags
|= [=tags ships=(set ship) new-tags=(set tag)] |= [=tags ships=(set ship) new-tags=(set tag)]
^+ tags ^+ tags

View File

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

View File

@ -23,7 +23,7 @@
:: /app-name/%app-name associations for app :: /app-name/%app-name associations for app
:: /group/%path associations for group :: /group/%path associations for group
:: ::
/- store=metadata-store /- store=metadata-store, pull-hook
/+ default-agent, verb, dbug, resource, *migrate /+ default-agent, verb, dbug, resource, *migrate
|% |%
+$ card card:agent:gall +$ card card:agent:gall
@ -95,15 +95,17 @@
~ ~
== ==
:: ::
+$ state-0 [%0 base-state-0] +$ state-0 [%0 base-state-0]
+$ state-1 [%1 base-state-0] +$ state-1 [%1 base-state-0]
+$ state-2 [%2 base-state-0] +$ state-2 [%2 base-state-0]
+$ state-3 [%3 base-state-1] +$ state-3 [%3 base-state-1]
+$ state-4 [%4 base-state-1] +$ state-4 [%4 base-state-1]
+$ state-5 [%5 base-state-1] +$ state-5 [%5 base-state-1]
+$ state-6 [%6 base-state-1] +$ state-6 [%6 base-state-1]
+$ state-7 [%7 base-state-2] +$ state-7 [%7 base-state-2]
+$ state-8 [%8 base-state-3] +$ state-8 [%8 base-state-3]
+$ state-9 [%9 base-state-3]
+$ state-10 [%10 base-state-3]
+$ versioned-state +$ versioned-state
$% state-0 $% state-0
state-1 state-1
@ -114,10 +116,12 @@
state-6 state-6
state-7 state-7
state-8 state-8
state-9
state-10
== ==
:: ::
+$ inflated-state +$ inflated-state
$: state-8 $: state-10
cached-indices cached-indices
== ==
-- --
@ -230,7 +234,7 @@
=| cards=(list card) =| cards=(list card)
|^ |^
=* loop $ =* loop $
?: ?=(%8 -.old) ?: ?=(%10 -.old)
:- cards :- cards
%_ state %_ state
associations associations.old associations associations.old
@ -238,6 +242,29 @@
group-indices (rebuild-group-indices associations.old) group-indices (rebuild-group-indices associations.old)
app-indices (rebuild-app-indices associations.old) app-indices (rebuild-app-indices associations.old)
== ==
?: ?=(%9 -.old)
=/ groups
(fall (~(get by (rebuild-app-indices associations.old)) %groups) ~)
=/ pokes=(list card)
%+ murn ~(tap in ~(key by groups))
|= group=resource
^- (unit card)
=/ =association:store (~(got by associations.old) [%groups group])
=* met metadatum.association
?. ?=([%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
!> ^- action:pull-hook
[%add entity.res res]
%_ $
cards (weld cards pokes)
-.old %10
==
?: ?=(%8 -.old)
$(-.old %9)
?: ?=(%7 -.old) ?: ?=(%7 -.old)
$(old [%8 (associations-2-to-3 associations.old) ~]) $(old [%8 (associations-2-to-3 associations.old) ~])
?: ?=(%6 -.old) ?: ?=(%6 -.old)
@ -369,7 +396,7 @@
^- (quip card _state) ^- (quip card _state)
|^ |^
=^ cards state =^ cards state
(on-load !>([%8 (remake-metadata ;;(tree-metadata +.arc))])) (on-load !>([%9 (remake-metadata ;;(tree-metadata +.arc))]))
:_ state :_ state
%+ weld cards %+ weld cards
%+ turn ~(tap in ~(key by group-indices)) %+ turn ~(tap in ~(key by group-indices))

View File

@ -22,8 +22,9 @@
++ notification-kind ++ notification-kind
^- (unit notif-kind:hark) ^- (unit notif-kind:hark)
=/ len (lent index.p.i) =/ len (lent index.p.i)
?: =(1 len) ~ =/ =mode:hark
`[%post [(dec len) len] %none %children] ?:(=(1 len) %count %none)
`[%post [(dec len) len] mode %children]
:: ::
++ transform-add-nodes ++ transform-add-nodes
|= [=index =post =atom was-parent-modified=?] |= [=index =post =atom was-parent-modified=?]

View File

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

View File

@ -7,9 +7,7 @@
:: ::
++ supported-apps ++ supported-apps
^- (list term) ^- (list term)
:~ %group-push-hook ~[%group-store]
%group-store
==
:: ::
++ poke-all-sane ++ poke-all-sane
|= =input |= =input

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. 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 ### 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 ## Extending

View File

@ -25,10 +25,10 @@ module Urbit.Arvo.Common
import Urbit.Prelude import Urbit.Prelude
import Control.Monad.Fail (fail) import Control.Monad.Fail (fail)
import Data.Bits
import Data.Serialize import Data.Serialize
import qualified Network.HTTP.Types.Method as H import qualified Network.HTTP.Types.Method as H
import qualified Network.Socket as N
import qualified Urbit.Ob as Ob import qualified Urbit.Ob as Ob
@ -159,6 +159,19 @@ deriveNoun ''JsonNode
-- Ames Destinations ------------------------------------------------- -- Ames Destinations -------------------------------------------------
serializeToNoun :: Serialize a => a -> Noun
serializeToNoun = A . bytesAtom . encode
serializeParseNoun :: Serialize a => String -> Int -> Noun -> Parser a
serializeParseNoun desc len = named (pack desc) . \case
A (atomBytes -> bs)
-- Atoms lose leading 0s, but since lsb, these become trailing NULs
| length bs <= len -> case decode $ bs <> replicate (len - length bs) 0 of
Right aa -> pure aa
Left msg -> fail msg
| otherwise -> fail ("putative " <> desc <> " " <> show bs <> " too long")
C{} -> fail ("unexpected cell in " <> desc)
newtype Patp a = Patp { unPatp :: a } newtype Patp a = Patp { unPatp :: a }
deriving newtype (Eq, Ord, Enum, Real, Integral, Num, ToNoun, FromNoun) deriving newtype (Eq, Ord, Enum, Real, Integral, Num, ToNoun, FromNoun)
@ -167,17 +180,29 @@ newtype Port = Port { unPort :: Word16 }
deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun) deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun)
-- @if -- @if
newtype Ipv4 = Ipv4 { unIpv4 :: Word32 } newtype Ipv4 = Ipv4 { unIpv4 :: N.HostAddress }
deriving newtype (Eq, Ord, Enum, Real, Integral, Num, ToNoun, FromNoun) deriving newtype (Eq, Ord, Enum)
instance Serialize Ipv4 where
get = (\a b c d -> Ipv4 $ N.tupleToHostAddress $ (d, c, b, a))
<$> getWord8 <*> getWord8 <*> getWord8 <*> getWord8
put (Ipv4 (N.hostAddressToTuple -> (a, b, c, d))) = for_ [d, c, b, a] putWord8
instance ToNoun Ipv4 where
toNoun = serializeToNoun
instance FromNoun Ipv4 where
parseNoun = serializeParseNoun "Ipv4" 4
instance Show Ipv4 where instance Show Ipv4 where
show (Ipv4 i) = show (Ipv4 (N.hostAddressToTuple -> (a, b, c, d))) =
show ((shiftR i 24) .&. 0xff) ++ "." ++ show a ++ "." ++
show ((shiftR i 16) .&. 0xff) ++ "." ++ show b ++ "." ++
show ((shiftR i 8) .&. 0xff) ++ "." ++ show c ++ "." ++
show (i .&. 0xff) show d
-- @is -- @is
-- should probably use hostAddress6ToTuple here, but no one uses it right now
newtype Ipv6 = Ipv6 { unIpv6 :: Word128 } newtype Ipv6 = Ipv6 { unIpv6 :: Word128 }
deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun) deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun)
@ -190,21 +215,14 @@ data AmesAddress = AAIpv4 Ipv4 Port
deriving (Eq, Ord, Show) deriving (Eq, Ord, Show)
instance Serialize AmesAddress where instance Serialize AmesAddress where
get = AAIpv4 <$> (Ipv4 <$> getWord32le) <*> (Port <$> getWord16le) get = AAIpv4 <$> get <*> (Port <$> getWord16le)
put (AAIpv4 (Ipv4 ip) (Port port)) = putWord32le ip >> putWord16le port put (AAIpv4 ip (Port port)) = put ip >> putWord16le port
instance FromNoun AmesAddress where instance FromNoun AmesAddress where
parseNoun = named "AmesAddress" . \case parseNoun = serializeParseNoun "AmesAddress" 6
A (atomBytes -> bs)
-- Atoms lose leading 0s, but since lsb, these become trailing NULs
| length bs <= 6 -> case decode $ bs <> replicate (6 - length bs) 0 of
Right aa -> pure aa
Left msg -> fail msg
| otherwise -> fail ("putative address " <> show bs <> " too long")
C{} -> fail "unexpected cell in ames address"
instance ToNoun AmesAddress where instance ToNoun AmesAddress where
toNoun = A . bytesAtom . encode toNoun = serializeToNoun
type AmesDest = Each Galaxy AmesAddress type AmesDest = Each Galaxy AmesAddress

View File

@ -80,10 +80,6 @@ data ShipClass
muk :: ByteString -> Word20 muk :: ByteString -> Word20
muk bs = mugBS bs .&. (2 ^ 20 - 1) muk bs = mugBS bs .&. (2 ^ 20 - 1)
-- XX check this
getAmesAddress :: Get AmesAddress
getAmesAddress = AAIpv4 <$> (Ipv4 <$> getWord32le) <*> (Port <$> getWord16le)
putAmesAddress :: Putter AmesAddress putAmesAddress :: Putter AmesAddress
putAmesAddress = \case putAmesAddress = \case
AAIpv4 (Ipv4 ip) (Port port) -> putWord32le ip >> putWord16le port AAIpv4 (Ipv4 ip) (Port port) -> putWord32le ip >> putWord16le port
@ -104,7 +100,7 @@ instance Serialize Packet where
guard isAmes guard isAmes
pktOrigin <- if isRelayed pktOrigin <- if isRelayed
then Just <$> getAmesAddress then Just <$> get
else pure Nothing else pure Nothing
-- body -- body
@ -157,9 +153,10 @@ instance Serialize Packet where
putWord32le head putWord32le head
case pktOrigin of case pktOrigin of
Just o -> putAmesAddress o Just o -> put o
Nothing -> pure () Nothing -> pure ()
putByteString body putByteString body
where where
putShipGetRank s@(Ship (LargeKey p q)) = case () of putShipGetRank s@(Ship (LargeKey p q)) = case () of
_ | s < 2 ^ 16 -> (0, putWord16le $ fromIntegral s) -- lord _ | s < 2 ^ 16 -> (0, putWord16le $ fromIntegral s) -- lord

View File

@ -4,8 +4,12 @@
1. Opens a UDP socket and makes sure that it stays open. 1. Opens a UDP socket and makes sure that it stays open.
- If can't open the port, wait and try again repeatedly. - If can't open the port, wait and try again repeatedly.
- If there is an error reading or writting from the open socket, - If there is an error reading to or writing from the open socket,
close it and open another. close it and open another, making sure, however, to reuse the
same port
NOTE: It's not clear what, if anything, closing and reopening
the socket does. We're keeping this behavior out of conservatism
until we understand it better.
2. Receives packets from the socket. 2. Receives packets from the socket.
@ -158,7 +162,7 @@ realUdpServ
-> HostAddress -> HostAddress
-> AmesStat -> AmesStat
-> RIO e UdpServ -> RIO e UdpServ
realUdpServ por hos sat = do realUdpServ startPort hos sat = do
logInfo $ displayShow ("AMES", "UDP", "Starting real UDP server.") logInfo $ displayShow ("AMES", "UDP", "Starting real UDP server.")
env <- ask env <- ask
@ -202,23 +206,30 @@ realUdpServ por hos sat = do
did <- atomically (tryWriteTBQueue qSend (a, b)) did <- atomically (tryWriteTBQueue qSend (a, b))
when (did == False) $ do when (did == False) $ do
logWarn "AMES: UDP: Dropping outbound packet because queue is full." logWarn "AMES: UDP: Dropping outbound packet because queue is full."
let opener por = do
logInfo $ displayShow $ ("AMES", "UDP", "Trying to open socket, port",)
por
sk <- forceBind por hos
sn <- io $ getSocketName sk
sp <- io $ socketPort sk
logInfo $ displayShow $ ("AMES", "UDP", "Got socket", sn, sp)
tOpen <- async $ forever $ do let waitForRelease = do
sk <- forceBind por hos atomically (writeTVar vSock (Just sk))
sn <- io $ getSocketName sk broken <- atomically (takeTMVar vFail)
logWarn "AMES: UDP: Closing broken socket."
io (close broken)
let waitForRelease = do case sn of
atomically (writeTVar vSock (Just sk)) (SockAddrInet boundPort _) ->
broken <- atomically (takeTMVar vFail) -- When we're on IPv4, maybe port forward at the NAT.
logWarn "AMES: UDP: Closing broken socket." rwith (requestPortAccess $ fromIntegral boundPort) $
io (close broken) \() -> waitForRelease
_ -> waitForRelease
case sn of opener sp
(SockAddrInet boundPort _) ->
-- When we're on IPv4, maybe port forward at the NAT. tOpen <- async $ opener startPort
rwith (requestPortAccess $ fromIntegral boundPort) $
\() -> waitForRelease
_ -> waitForRelease
tSend <- async $ forever $ join $ atomically $ do tSend <- async $ forever $ join $ atomically $ do
(adr, byt) <- readTBQueue qSend (adr, byt) <- readTBQueue qSend

View File

@ -37,12 +37,12 @@ textPlain = Path [(MkKnot "text"), (MkKnot "plain")]
-- | Filter for dotfiles, tempfiles and backup files. -- | Filter for dotfiles, tempfiles and backup files.
validClaySyncPath :: FilePath -> Bool validClaySyncPath :: FilePath -> Bool
validClaySyncPath fp = hasPeriod && notTildeFile && notDotHash && notDoubleHash validClaySyncPath fp = hasPeriod && notTildeFile && notDotFile && notDoubleHash
where where
fileName = takeFileName fp fileName = takeFileName fp
hasPeriod = elem '.' fileName hasPeriod = elem '.' fileName
notTildeFile = not $ "~" `isSuffixOf` fileName notTildeFile = not $ "~" `isSuffixOf` fileName
notDotHash = not $ ".#" `isPrefixOf` fileName notDotFile = not $ "." `isPrefixOf` fileName
notDoubleHash = notDoubleHash =
not $ ("#" `isPrefixOf` fileName) && ("#" `isSuffixOf` fileName) not $ ("#" `isPrefixOf` fileName) && ("#" `isSuffixOf` fileName)

View File

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

View File

@ -32,7 +32,7 @@ same (if [developing on a local development ship][local]). Then, from
'pkg/interface': 'pkg/interface':
``` ```
npm install npm ci
npm run start npm run start
``` ```
@ -59,7 +59,7 @@ module.exports = {
``` ```
The dev environment will attempt to match the subdomain against the keys of this 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`, 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 `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. 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 ```bash
$ cd ./pkg/interface $ cd ./pkg/interface
$ npm install $ npm ci
$ npm run lint $ npm run lint
``` ```

View File

@ -98,8 +98,9 @@
"lint-file": "eslint", "lint-file": "eslint",
"tsc": "tsc", "tsc": "tsc",
"tsc:watch": "tsc --watch", "tsc:watch": "tsc --watch",
"preinstall": "./preinstall.sh",
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js", "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", "start": "webpack-dev-server --config config/webpack.dev.js",
"test": "echo \"Error: no test specified\" && exit 1" "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 { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
import { BigInteger } from 'big-integer'; import { BigInteger } from 'big-integer';
import { getParentIndex } from '../lib/notification'; import { getParentIndex } from '../lib/notification';
import useHarkState from '../state/hark';
function getHarkSize() {
return useHarkState.getState().notifications.size ?? 0;
}
export class HarkApi extends BaseApi<StoreState> { export class HarkApi extends BaseApi<StoreState> {
private harkAction(action: any): Promise<any> { private harkAction(action: any): Promise<any> {
@ -172,10 +177,10 @@ export class HarkApi extends BaseApi<StoreState> {
} }
async getMore(): Promise<boolean> { async getMore(): Promise<boolean> {
const offset = this.store.state['notifications']?.size || 0; const offset = getHarkSize();
const count = 3; const count = 3;
await this.getSubset(offset, count, false); await this.getSubset(offset, count, false);
return offset === (this.store.state.notifications?.size || 0); return offset === getHarkSize();
} }
async getSubset(offset:number, count:number, isArchive: boolean) { async getSubset(offset:number, count:number, isArchive: boolean) {

View File

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

View File

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

View File

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

View File

@ -16,5 +16,5 @@ export function useCopy(copied: string, display: string) {
display, display,
]); ]);
return { copyDisplay, doCopy }; return { copyDisplay, doCopy, didCopy };
} }

View File

@ -11,6 +11,7 @@ export function distanceToBottom(el: HTMLElement) {
export function useLazyScroll( export function useLazyScroll(
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
ready: boolean,
margin: number, margin: number,
count: number, count: number,
loadMore: () => Promise<boolean> loadMore: () => Promise<boolean>
@ -41,7 +42,13 @@ export function useLazyScroll(
}, [count]); }, [count]);
useEffect(() => { useEffect(() => {
if (!ref.current || isDone) { if(!ready) {
setIsDone(false);
}
}, [ready]);
useEffect(() => {
if (!ref.current || isDone || !ready) {
return; return;
} }
const scroll = ref.current; const scroll = ref.current;
@ -57,7 +64,7 @@ export function useLazyScroll(
return () => { return () => {
ref.current?.removeEventListener('scroll', onScroll); ref.current?.removeEventListener('scroll', onScroll);
}; };
}, [ref?.current, count]); }, [ref?.current, ready, isDone]);
return { isDone, isLoading }; return { isDone, isLoading };
} }

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import f, { compose, memoize } from 'lodash/fp'; import f, { compose, memoize } from 'lodash/fp';
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
@ -63,6 +63,16 @@ export function unixToDa(unix: number) {
return DA_UNIX_EPOCH.add(timeSinceEpoch); 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) { export function makePatDa(patda: string) {
return bigInt(udToDec(patda)); return bigInt(udToDec(patda));
} }
@ -400,11 +410,15 @@ interface useHoveringInterface {
export const useHovering = (): useHoveringInterface => { export const useHovering = (): useHoveringInterface => {
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const bind = { const onMouseOver = useCallback(() => setHovering(true), [])
onMouseOver: () => setHovering(true), const onMouseLeave = useCallback(() => setHovering(false), [])
onMouseLeave: () => setHovering(false) const bind = useMemo(() => ({
}; onMouseOver,
return { hovering, bind }; onMouseLeave,
}), [onMouseLeave, onMouseOver]);
return useMemo(() => ({ hovering, bind }), [hovering, bind]);
}; };
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/; const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;

View File

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

View File

@ -16,7 +16,7 @@ const useGroupState = createState<GroupState>('Group', {
}, ['groups']); }, ['groups']);
export function useGroup(group: string) { 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) { export function useGroupForAssoc(association: Association) {

View File

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

View File

@ -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"; import { BaseState, createState } from "./base";
@ -9,6 +11,19 @@ export interface MetadataState extends BaseState<MetadataState> {
// preview: (group: string) => Promise<MetadataUpdatePreview>; // 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', { const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} }, associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
// preview: async (group): Promise<MetadataUpdatePreview> => { // preview: async (group): Promise<MetadataUpdatePreview> => {
@ -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, categories: leapCategories,
}, },
tutorial: { tutorial: {
seen: false, seen: true,
joined: undefined joined: undefined
} }
}); });

View File

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

View File

@ -1,4 +1,5 @@
/* eslint-disable max-lines-per-function */ /* eslint-disable max-lines-per-function */
import bigInt from 'big-integer';
import React, { import React, {
useState, useState,
useEffect, useEffect,
@ -19,7 +20,8 @@ import {
writeText, writeText,
useShowNickname, useShowNickname,
useHideAvatar, useHideAvatar,
useHovering useHovering,
daToUnix
} from '~/logic/lib/util'; } from '~/logic/lib/util';
import { import {
Group, Group,
@ -29,8 +31,8 @@ import {
Groups, Groups,
Associations Associations
} from '~/types'; } from '~/types';
import TextContent from './content/text'; import TextContent from '../../../landscape/components/Graph/content/text';
import CodeContent from './content/code'; import CodeContent from '../../../landscape/components/Graph/content/code';
import RemoteContent from '~/views/components/RemoteContent'; import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from '~/views/components/MentionText'; import { Mention } from '~/views/components/MentionText';
import { Dropdown } from '~/views/components/Dropdown'; import { Dropdown } from '~/views/components/Dropdown';
@ -42,8 +44,7 @@ import useContactState from '~/logic/state/contact';
import { useIdlingState } from '~/logic/lib/idling'; import { useIdlingState } from '~/logic/lib/idling';
import ProfileOverlay from '~/views/components/ProfileOverlay'; import ProfileOverlay from '~/views/components/ProfileOverlay';
import {useCopy} from '~/logic/lib/useCopy'; import {useCopy} from '~/logic/lib/useCopy';
import {PermalinkEmbed} from '../../permalinks/embed'; import {GraphContentWide} from '~/views/landscape/components/Graph/GraphContentWide';
import {referenceToPermalink} from '~/logic/lib/permalinks';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D'; export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -296,15 +297,20 @@ class ChatMessage extends Component<ChatMessageProps> {
); );
} }
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const nextDate = nextMsg ? (
daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null;
const dayBreak = const dayBreak =
nextMsg && nextMsg &&
new Date(msg['time-sent']).getDate() !== new Date(date).getDate() !==
new Date(nextMsg['time-sent']).getDate(); new Date(nextDate).getDate();
const containerClass = `${isPending ? 'o-40' : ''} ${className}`; const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
const timestamp = moment const timestamp = moment
.unix(msg['time-sent'] / 1000) .unix(date / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm'); .format(renderSigil ? 'h:mm A' : 'h:mm');
const messageProps = { const messageProps = {
@ -340,7 +346,7 @@ class ChatMessage extends Component<ChatMessageProps> {
style={style} style={style}
> >
{dayBreak && !isLastRead ? ( {dayBreak && !isLastRead ? (
<DayBreak when={msg['time-sent']} shimTop={renderSigil} /> <DayBreak when={date} shimTop={renderSigil} />
) : null} ) : null}
{renderSigil ? ( {renderSigil ? (
<MessageWrapper {...messageProps}> <MessageWrapper {...messageProps}>
@ -358,7 +364,7 @@ class ChatMessage extends Component<ChatMessageProps> {
association={association} association={association}
api={api} api={api}
dayBreak={dayBreak} dayBreak={dayBreak}
when={msg['time-sent']} when={date}
ref={unreadMarkerRef} ref={unreadMarkerRef}
/> />
) : null} ) : null}
@ -388,20 +394,21 @@ export const MessageAuthor = ({
const dark = theme === 'dark' || (theme === 'auto' && osDark); const dark = theme === 'dark' || (theme === 'auto' && osDark);
const contacts = useContactState((state) => state.contacts); const contacts = useContactState((state) => state.contacts);
const date = daToUnix(bigInt(msg.index.split('/')[1]));
const datestamp = moment const datestamp = moment
.unix(msg['time-sent'] / 1000) .unix(date / 1000)
.format(DATESTAMP_FORMAT); .format(DATESTAMP_FORMAT);
const contact = const contact =
((msg.author === window.ship && showOurContact) || ((msg.author === window.ship && showOurContact) ||
msg.author !== window.ship) && msg.author !== window.ship) &&
`~${msg.author}` in contacts `~${msg.author}` in contacts
? contacts[`~${msg.author}`] ? contacts[`~${msg.author}`]
: false; : undefined;
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
const { hideAvatars } = useSettingsState(selectCalmState); const { hideAvatars } = useSettingsState(selectCalmState);
const shipName = showNickname ? contact.nickname : cite(msg.author); const shipName = showNickname && contact?.nickname || cite(msg.author) || `~${msg.author}`;
const copyNotice = 'Copied';
const color = contact const color = contact
? `#${uxToHex(contact.color)}` ? `#${uxToHex(contact.color)}`
: dark : dark
@ -412,28 +419,10 @@ export const MessageAuthor = ({
: dark : dark
? 'mix-blend-diff' ? 'mix-blend-diff'
: 'mix-blend-darken'; : 'mix-blend-darken';
const [displayName, setDisplayName] = useState(shipName);
const [nameMono, setNameMono] = useState(showNickname ? false : true); const { copyDisplay, doCopy, didCopy } = useCopy(`~${msg.author}`, shipName);
const { hovering, bind } = useHovering(); const { hovering, bind } = useHovering();
const [showOverlay, setShowOverlay] = useState(false); const nameMono = !(showNickname || didCopy);
const toggleOverlay = () => {
setShowOverlay((value) => !value);
};
const showCopyNotice = () => {
setDisplayName(copyNotice);
setNameMono(false);
};
useEffect(() => {
const resetDisplay = () => {
setDisplayName(shipName);
setNameMono(showNickname ? false : true);
};
const timer = setTimeout(() => resetDisplay(), 800);
return () => clearTimeout(timer);
}, [shipName, displayName]);
const img = const img =
contact?.avatar && !hideAvatars ? ( contact?.avatar && !hideAvatars ? (
@ -470,10 +459,7 @@ export const MessageAuthor = ({
return ( return (
<Box display='flex' alignItems='flex-start' {...rest}> <Box display='flex' alignItems='flex-start' {...rest}>
<Box <Box
onClick={() => { height={24}
setShowOverlay(true);
}}
height={24}
pr={2} pr={2}
mt={'1px'} mt={'1px'}
pl={'12px'} pl={'12px'}
@ -500,13 +486,10 @@ export const MessageAuthor = ({
mono={nameMono} mono={nameMono}
fontWeight={nameMono ? '400' : '500'} fontWeight={nameMono ? '400' : '500'}
cursor='pointer' cursor='pointer'
onClick={() => { onClick={doCopy}
writeText(`~${msg.author}`);
showCopyNotice();
}}
title={`~${msg.author}`} title={`~${msg.author}`}
> >
{displayName} {copyDisplay}
</Text> </Text>
<Text flexShrink={0} fontSize={0} gray> <Text flexShrink={0} fontSize={0} gray>
{timestamp} {timestamp}
@ -538,7 +521,6 @@ export const Message = ({
...rest ...rest
}) => { }) => {
const { hovering, bind } = useHovering(); const { hovering, bind } = useHovering();
const contacts = useContactState((state) => state.contacts);
return ( return (
<Box width="100%" position='relative' {...rest}> <Box width="100%" position='relative' {...rest}>
{timestampHover ? ( {timestampHover ? (
@ -557,66 +539,14 @@ export const Message = ({
) : ( ) : (
<></> <></>
)} )}
<Box width="100%" {...bind}> <GraphContentWide
{msg.contents.map((content, i) => { {...bind}
switch (Object.keys(content)[0]) { width="100%"
case 'text': post={msg}
return ( transcluded={transcluded}
<TextContent api={api}
key={i} showOurContact={showOurContact}
api={api} />
fontSize={1}
lineHeight={'20px'}
content={content}
/>
);
case 'code':
return <CodeContent key={i} content={content} />;
case 'reference':
const { link } = referenceToPermalink(content);
return (
<PermalinkEmbed
link={link}
api={api}
transcluded={transcluded}
showOurContact={showOurContact}
/>
);
case 'url':
return (
<Box
key={i}
flexShrink={0}
fontSize={1}
lineHeight='20px'
color='black'
width="fit-content"
maxWidth="min(500px, 100%)"
>
<RemoteContent
key={content.url}
url={content.url}
/>
</Box>
);
case 'mention':
const first = (i) => i === 0;
return (
<Mention
key={i}
first={first(i)}
group={group}
scrollWindow={scrollWindow}
ship={content.mention}
contact={contacts?.[`~${content.mention}`]}
api={api}
/>
);
default:
return null;
}
})}
</Box>
</Box> </Box>
); );
}; };

View File

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

View File

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

View File

@ -1,65 +1,80 @@
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from "react";
import moment from 'moment'; import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from '@tlon/indigo-react'; import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from "react-router-dom";
import _ from 'lodash'; import _ from "lodash";
import { import {
GraphNotifIndex, GraphNotifIndex,
GraphNotificationContents, GraphNotificationContents,
Associations, Associations,
Rolodex, Rolodex,
Groups Groups,
} from '~/types'; } from "~/types";
import { Header } from './header'; import { Header } from "./header";
import { cite, deSig, pluralize, useShowNickname } from '~/logic/lib/util'; import {
import Author from '~/views/components/Author'; cite,
import GlobalApi from '~/logic/api/global'; deSig,
import { getSnippet } from '~/logic/lib/publish'; pluralize,
import styled from 'styled-components'; useShowNickname,
import { MentionText } from '~/views/components/MentionText'; isDm,
import ChatMessage from '../chat/components/ChatMessage'; } from "~/logic/lib/util";
import useContactState from '~/logic/state/contact'; import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
import useGroupState from '~/logic/state/group'; import Author from "~/views/components/Author";
import useMetadataState from '~/logic/state/metadata'; import GlobalApi from "~/logic/api/global";
import {PermalinkEmbed} from '../permalinks/embed'; import styled from "styled-components";
import {parsePermalink, referenceToPermalink} from '~/logic/lib/permalinks'; 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) { function getGraphModuleIcon(module: string) {
if (module === 'link') { if (module === "link") {
return 'Collection'; return "Collection";
} }
if(module === 'post') { if (module === "post") {
return 'Groups'; return "Groups";
} }
return _.capitalize(module); return _.capitalize(module);
} }
const FilterBox = styled(Box)` function describeNotification(
background: linear-gradient( description: string,
to bottom, plural: boolean,
transparent, isDm: boolean,
${(p) => p.theme.colors.white} singleAuthor: boolean
); ): string {
`;
function describeNotification(description: string, plural: boolean): string {
switch (description) { switch (description) {
case 'post': case "post":
return 'replied to you'; return singleAuthor ? "replied to you" : "Your post received replies";
case 'link': case "link":
return `added ${pluralize('new link', plural)} to`; return `New link${plural ? "s" : ""} in`;
case 'comment': case "comment":
return `left ${pluralize('comment', plural)} on`; return `New comment${plural ? "s" : ""} on`;
case 'edit-comment': case "note":
return `updated ${pluralize('comment', plural)} on`; return `New Note${plural ? "s" : ""} in`;
case 'note': case "edit-note":
return `posted ${pluralize('note', plural)} to`; return `updated ${pluralize("note", plural)} in`;
case 'edit-note': case "mention":
return `updated ${pluralize('note', plural)} in`; return singleAuthor ? "mentioned you in" : "You were mentioned in";
case 'mention': case "message":
return 'mentioned you on'; if (isDm) {
case 'message': return "messaged you";
return `sent ${pluralize('message', plural)} to`; }
return `New message${plural ? "s" : ""} in`;
default: default:
return description; return description;
} }
@ -67,105 +82,87 @@ function describeNotification(description: string, plural: boolean): string {
const GraphUrl = ({ contents, api }) => { const GraphUrl = ({ contents, api }) => {
const [{ text }, link] = contents; const [{ text }, link] = contents;
if('reference' in link) { if ("reference" in link) {
return ( return (
<PermalinkEmbed <PermalinkEmbed
transcluded={1} transcluded={1}
link={referenceToPermalink(link).link} link={referenceToPermalink(link).link}
api={api} api={api}
showOurContact showOurContact
/>); />
);
} }
return ( return (
<Box borderRadius='2' p='2' bg='scales.black05'> <Box borderRadius="2" p="2" bg="scales.black05">
<Anchor underline={false} target='_blank' color='black' href={link.url}> <Anchor underline={false} target="_blank" color="black" href={link.url}>
<Icon verticalAlign='bottom' mr='2' icon='ArrowExternal' /> <Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{text} {text}
</Anchor> </Anchor>
</Box> </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 = ({ export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
group,
association,
post,
mod,
index,
}) => {
const { contents } = post; const { contents } = post;
const idx = index.slice(1).split('/'); const idx = index.slice(1).split("/");
if (mod === 'link') { const url = getNodeUrl(mod, hidden, association?.group, association?.resource, index);
if (idx.length === 1) { if (mod === "link" && idx.length === 1) {
return <GraphUrl contents={contents} />; const [{ text: title }] = 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') {
return ( return (
<Row <ContentSummary to={url} icon="Links" name={title} author={post.author} />
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>
); );
} }
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( function getNodeUrl(
@ -175,78 +172,103 @@ function getNodeUrl(
graph: string, graph: string,
index: string index: string
) { ) {
if (hidden && mod === 'chat') { if (hidden && mod === "chat") {
groupPath = '/messages'; groupPath = "/messages";
} else if (hidden) { } else if (hidden) {
groupPath = '/home'; groupPath = "/home";
} }
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`; const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
const idx = index.slice(1).split('/'); const idx = index.slice(1).split("/");
if (mod === 'publish') { if (mod === "publish") {
const [noteId] = idx; console.log(idx);
return `${graphUrl}/note/${noteId}`; const [noteId, kind, commId] = idx;
} else if (mod === 'link') { const selected = kind === "2" ? `?selected=${commId}` : "";
const [linkId] = idx; return `${graphUrl}/note/${noteId}${selected}`;
return `${graphUrl}/index/${linkId}`; } else if (mod === "link") {
} else if (mod === 'chat') { const [linkId, commId] = idx;
if(idx.length > 0) { return `${graphUrl}/index/${linkId}${commId ? `?selected=${commId}` : ""}`;
} else if (mod === "chat") {
if (idx.length > 0) {
return `${graphUrl}?msg=${idx[0]}`; return `${graphUrl}?msg=${idx[0]}`;
} }
return graphUrl; return graphUrl;
} else if( mod === 'post') { } else if (mod === "post") {
return `/~landscape${groupPath}/feed${index}`; 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); interface PostsByAuthor {
const association = useMetadataState( author: string;
useCallback(s => s.associations.graph[graph], [graph]) 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 ( return (
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}> <>
<Col flexGrow={1} alignItems='flex-start'> {_.map(postsByConsecAuthor, ({ posts, author }, idx) => {
{showContact && ( const time = posts[0]?.["time-sent"];
<Author showImage ship={author} date={time} group={group} /> return (
)} <Col key={idx} flexGrow={1} alignItems="flex-start">
<Row width='100%' p='1' flexDirection='column'> {!hideAuthors && (
<GraphNodeContent <Author
post={post} size={24}
mod={mod} sigilPadding={6}
description={description} showImage
association={association} ship={author}
index={index} date={time}
group={group} />
remoteContentPolicy={{}} )}
/> <Col gapY="2" py={hideAuthors ? 0 : 2} width="100%">
</Row> {_.map(posts, (post) => (
</Col> <GraphNodeContent
</Row> 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; api: GlobalApi;
}) { }) {
const { contents, index, read, time, api, timebox } = props; 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 { graph, group } = index;
const icon = getGraphModuleIcon(index.module); const association = useAssocForGraph(graph)!;
const desc = describeNotification(index.description, contents.length !== 1); 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(() => { const onClick = useCallback(() => {
if (props.archived || read) { if (
return; !(
(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]); }, [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 ( return (
<> <>
<Header <Header
onClick={onClick}
archived={props.archived}
time={time} time={time}
read={read} authors={authorsInHeader ? authors : []}
authors={authors} channelTitle={channelTitle}
moduleIcon={icon}
channel={graph}
group={group}
description={desc} description={desc}
groupTitle={groupTitle}
content
/> />
<Box flexGrow={1} width='100%' pl={5} gridArea='main'> <Col onClick={onClick} gapY="2" flexGrow={1} width="100%" gridArea="main">
{_.map(contents, (content, idx) => ( <GraphNodes
<GraphNode hideAuthors={hideAuthors}
post={content} posts={contents.slice(0, 4)}
author={content.author} mod={index.module}
mod={index.module} description={index.description}
time={content?.['time-sent']} index={contents?.[0].index}
description={index.description} association={association}
index={content.index} hidden={groups[association?.group]?.hidden}
graph={graph} />
group={groups[group]} {contents.length > 4 && (
groupPath={group} <Text mb="2" gray>
read={read} + {contents.length - 4} more
onRead={onClick} </Text>
showContact={idx === 0} )}
/> </Col>
))}
</Box>
</> </>
); );
} }

View File

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

View File

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

View File

@ -25,6 +25,7 @@ import { Invites } from './invites';
import { useLazyScroll } from '~/logic/lib/useLazyScroll'; import { useLazyScroll } from '~/logic/lib/useLazyScroll';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import useInviteState from '~/logic/state/invite'; import useInviteState from '~/logic/state/invite';
import useMetadataState from '~/logic/state/metadata';
type DatedTimebox = [BigInteger, Timebox]; type DatedTimebox = [BigInteger, Timebox];
@ -64,6 +65,10 @@ export default function Inbox(props: {
}; };
}, []); }, []);
const ready = useHarkState(
s => Object.keys(s.unreads.graph).length > 0
);
const notificationState = useHarkState(state => state.notifications); const notificationState = useHarkState(state => state.notifications);
const archivedNotifications = useHarkState(state => state.archivedNotifications); const archivedNotifications = useHarkState(state => state.archivedNotifications);
@ -109,13 +114,14 @@ export default function Inbox(props: {
const { isDone, isLoading } = useLazyScroll( const { isDone, isLoading } = useLazyScroll(
scrollRef, scrollRef,
ready,
0.2, 0.2,
_.flatten(notifications).length, _.flatten(notifications).length,
loadMore loadMore
); );
return ( 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} /> <Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => { {[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!; const timeboxes = notificationsByDayMap.get(day)!;
@ -169,26 +175,15 @@ function DaySection({
return ( 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(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => ( _.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}> <Notification
{(i !== 0 || j !== 0) && ( key={j}
<Box flexShrink={0} height="4px" bg="scales.black05" /> api={api}
)} notification={not}
<Notification archived={archive}
api={api} time={date}
notification={not} />
archived={archive}
time={date}
/>
</React.Fragment>
)) ))
)} )}
</> </>

View File

@ -59,13 +59,6 @@ export function Invites(props: InvitesProps): ReactElement {
return ( 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) {Object.keys(invitesAndStatus)
.sort(alphabeticalOrder) .sort(alphabeticalOrder)
.map((resource) => { .map((resource) => {
@ -89,10 +82,9 @@ export function Invites(props: InvitesProps): ReactElement {
invite={invite} invite={invite}
app={app} app={app}
uid={uid} uid={uid}
join={join}
resource={resource} resource={resource}
/> />
); );
} }
}) })
} }

View File

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

View File

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

View File

@ -64,20 +64,20 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
{contact?.cover ? ( {contact?.cover ? (
<div> <div>
{editCover ? ( {editCover ? (
<ImageInput id='cover' marginTop='-8px' /> <ImageInput id='cover' marginTop='-8px' width='288px' />
) : ( ) : (
<Row> <Row>
<Button mr='2' onClick={() => setEditCover(true)}> <Button mr='2' onClick={() => setEditCover(true)}>
Replace Header Replace Header
</Button> </Button>
<Button onClick={(e) => handleClear(e)}> <Button onClick={e => handleClear(e)}>
{removedCoverLabel} {removedCoverLabel}
</Button> </Button>
</Row> </Row>
)} )}
</div> </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`); 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> </Text>
<SetStatusBarModal <SetStatusBarModal
isControl isControl
@ -183,7 +189,7 @@ export function Profile(props: any): ReactElement | null {
} }
return ( return (
<Center p={[0, 4]} height='100%' width='100%'> <Center p={[3, 4]} height='100%' width='100%'>
<Box maxWidth='600px' width='100%' position='relative'> <Box maxWidth='600px' width='100%' position='relative'>
{ isEdit ? ( { isEdit ? (
<EditProfile <EditProfile

View File

@ -1,10 +1,10 @@
import React from 'react'; import React, {useEffect, useRef} from 'react';
import { Box } from '@tlon/indigo-react'; import { Box } from '@tlon/indigo-react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { StoreState } from '~/logic/store/type'; import { StoreState } from '~/logic/store/type';
import { Association } from '@urbit/api'; import { Association } from '@urbit/api';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps, useLocation } from 'react-router-dom';
import { NotebookRoutes } from './components/NotebookRoutes'; import { NotebookRoutes } from './components/NotebookRoutes';
type PublishResourceProps = StoreState & { type PublishResourceProps = StoreState & {
@ -17,9 +17,21 @@ export function PublishResource(props: PublishResourceProps) {
const { association, api, baseUrl, notebooks } = props; const { association, api, baseUrl, notebooks } = props;
const rid = association.resource; const rid = association.resource;
const [, , ship, book] = rid.split('/'); const [, , ship, book] = rid.split('/');
const location = useLocation();
const scrollRef = useRef(null)
useEffect(() => {
const search = new URLSearchParams(location.search);
if(search.has('selected') || search.has('edit') || !scrollRef.current) {
return;
}
scrollRef.current.scrollTop = 0;
}, [location])
return ( return (
<Box height="100%" width="100%" overflowY="auto"> <Box ref={scrollRef} height="100%" width="100%" overflowY="auto">
<NotebookRoutes <NotebookRoutes
api={api} api={api}
ship={ship} ship={ship}

View File

@ -94,12 +94,6 @@ export function Note(props: NoteProps & RouteComponentProps) {
const { doCopy, copyDisplay } = useCopy(permalink, 'Copy Link'); const { doCopy, copyDisplay } = useCopy(permalink, 'Copy Link');
const windowRef = React.useRef(null);
useEffect(() => {
if (windowRef.current && !query.has('selected')) {
windowRef.current.parentElement.scrollTop = 0;
}
}, [note, windowRef]);
return ( return (
<Box <Box
@ -112,7 +106,6 @@ export function Note(props: NoteProps & RouteComponentProps) {
width="100%" width="100%"
gridRowGap={4} gridRowGap={4}
mx="auto" mx="auto"
ref={windowRef}
> >
<Link to={rootUrl}> <Link to={rootUrl}>
<Text>{'<- Notebook Index'}</Text> <Text>{'<- Notebook Index'}</Text>

View File

@ -49,20 +49,20 @@
font-family: 'Source Code Pro'; 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 { font-family: "Source Code Pro"}
.publish .cm-s-tlon span.cm-meta { color: var(--gray); } .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-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-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-atom { font-weight: bold; color: var(--gray); }
.publish .cm-s-tlon span.cm-def { color: black; } .publish .cm-s-tlon span.cm-def { color: inherit; }
.publish .cm-s-tlon span.cm-variable { color: black; } .publish .cm-s-tlon span.cm-variable { color: inherit; }
.publish .cm-s-tlon span.cm-variable-2 { color: black; } .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: black; } .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: black; } .publish .cm-s-tlon span.cm-property { color: inherit; }
.publish .cm-s-tlon span.cm-operator { color: black; } .publish .cm-s-tlon span.cm-operator { color: inherit; }
.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-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 { color: var(--dark-gray); }
.publish .cm-s-tlon span.cm-string-2 { color: var(--gray); } .publish .cm-s-tlon span.cm-string-2 { color: var(--gray); }
.publish .cm-s-tlon span.cm-qualifier { color: #555; } .publish .cm-s-tlon span.cm-qualifier { color: #555; }

View File

@ -1,32 +1,36 @@
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { import {
Box,
Text, Text,
Row, Row,
Label, Label,
Col, Col,
ManagedRadioButtonField as Radio, ManagedRadioButtonField as Radio
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { ImageInput } from '~/views/components/ImageInput'; import { ImageInput } from '~/views/components/ImageInput';
import { ColorInput } from '~/views/components/ColorInput'; import { ColorInput } from '~/views/components/ColorInput';
import { StorageState } from '~/types';
export type BgType = 'none' | 'url' | 'color'; export type BgType = 'none' | 'url' | 'color';
export function BackgroundPicker({ export function BackgroundPicker({
bgType, bgType,
bgUrl, bgUrl,
api, api
}: { }: {
bgType: BgType; bgType: BgType;
bgUrl?: string; bgUrl?: string;
api: GlobalApi; api: GlobalApi;
}): ReactElement { }): ReactElement {
const rowSpace = { my: 0, alignItems: 'center' }; 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 ( return (
<Col> <Col>
<Label>Landscape Background</Label> <Label>Landscape Background</Label>
@ -40,7 +44,7 @@ export function BackgroundPicker({
id="bgUrl" id="bgUrl"
placeholder="Drop or upload a file, or paste a link here" placeholder="Drop or upload a file, or paste a link here"
name="bgUrl" name="bgUrl"
url={bgUrl || ""} url={bgUrl || ''}
/> />
</Col> </Col>
</Row> </Row>
@ -48,13 +52,13 @@ export function BackgroundPicker({
<Col {...colProps}> <Col {...colProps}>
<Radio mb="1" label="Color" id="color" name="bgType" /> <Radio mb="1" label="Color" id="color" name="bgType" />
<Text ml="5" gray>Set a hex-based background</Text> <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> </Col>
</Row> </Row>
<Radio <Radio
my="3" my="3"
caption="Your home screen will simply render as its respective day/night mode color" caption="Your home screen will simply render as its respective day/night mode color"
name="bgType" name="bgType"
label="None" label="None"
id="none" /> id="none" />
</Col> </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 useHarkState from "~/logic/state/hark";
import _ from "lodash"; import _ from "lodash";
import {AsyncButton} from "~/views/components/AsyncButton"; import {AsyncButton} from "~/views/components/AsyncButton";
import {GroupChannelPicker} from "./GroupChannelPicker";
import {isWatching} from "~/logic/lib/hark";
interface FormSchema { interface FormSchema {
mentions: boolean; mentions: boolean;
dnd: boolean; dnd: boolean;
watchOnSelf: boolean; watchOnSelf: boolean;
graph: {
[rid: string]: boolean;
};
groups: {
[rid: string]: boolean;
}
} }
export function NotificationPreferences(props: { export function NotificationPreferences(props: {
@ -23,6 +31,7 @@ export function NotificationPreferences(props: {
const { api } = props; const { api } = props;
const dnd = useHarkState(state => state.doNotDisturb); const dnd = useHarkState(state => state.doNotDisturb);
const graphConfig = useHarkState(state => state.notificationsGraphConfig); const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const groupConfig = useHarkState(s => s.notificationsGroupConfig);
const initialValues = { const initialValues = {
mentions: graphConfig.mentions, mentions: graphConfig.mentions,
dnd: dnd, dnd: dnd,
@ -41,6 +50,16 @@ export function NotificationPreferences(props: {
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) { if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
promises.push(api.hark.setDoNotDisturb(values.dnd)) promises.push(api.hark.setDoNotDisturb(values.dnd))
} }
_.forEach(values.graph, (listen: boolean, graph: string) => {
if(listen !== isWatching(graphConfig, graph)) {
promises.push(api.hark[listen ? "listenGraph" : "ignoreGraph"](graph, "/"))
}
});
_.forEach(values.groups, (listen: boolean, group: string) => {
if(listen !== groupConfig.includes(group)) {
promises.push(api.hark[listen ? "listenGroup" : "ignoreGroup"](group));
}
});
await Promise.all(promises); await Promise.all(promises);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
@ -81,6 +100,15 @@ export function NotificationPreferences(props: {
id="mentions" id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined" caption="Notify me if someone mentions my @p in a channel I've joined"
/> />
<Col gapY="3">
<Text lineHeight="tall">
Activity
</Text>
<Text gray>
Set which groups will send you notifications.
</Text>
<GroupChannelPicker />
</Col>
<AsyncButton primary width="fit-content"> <AsyncButton primary width="fit-content">
Save Save
</AsyncButton> </AsyncButton>

View File

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

View File

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

View File

@ -15,6 +15,7 @@ import { getLatestCommentRevision } from '~/logic/lib/publish';
import {useCopy} from '~/logic/lib/useCopy'; import {useCopy} from '~/logic/lib/useCopy';
import { getPermalinkForGraph} from '~/logic/lib/permalinks'; import { getPermalinkForGraph} from '~/logic/lib/permalinks';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import {GraphContentWide} from '../landscape/components/Graph/GraphContentWide';
const ClickBox = styled(Box)` const ClickBox = styled(Box)`
cursor: pointer; cursor: pointer;
@ -95,25 +96,22 @@ export function CommentItem(props: CommentItemProps): ReactElement {
unread={props.unread} unread={props.unread}
group={group} group={group}
> >
<Row px="2" gapX="2" alignItems="center"> <Row px="2" gapX="2" height="18px">
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action> <Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
{adminLinks} {adminLinks}
</Row> </Row>
</Author> </Author>
</Row> </Row>
<Box <GraphContentWide
borderRadius="1" borderRadius="1"
p="1" p="1"
mb="1" mb="1"
backgroundColor={props.highlighted ? 'washedBlue' : 'white'} backgroundColor={props.highlighted ? 'washedBlue' : 'white'}
> transcluded={0}
<MentionText api={api}
transcluded={0} post={post}
api={api} showOurContact
group={group} />
content={post?.contents}
/>
</Box>
</Box> </Box>
); );
} }

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

@ -1,8 +1,7 @@
import React, { useEffect, useState, useLayoutEffect, ReactElement } from 'react'; import React, { useEffect, useState, useLayoutEffect, ReactElement } from 'react';
import { useHistory } from 'react-router-dom';
import { Box, Text, Row, Col } from '@tlon/indigo-react'; import { Box, Text, Row, Col } from '@tlon/indigo-react';
import { Associations, Groups } from '@urbit/api'; import { Associations, Groups } from '@urbit/api';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { MetadataIcon } from '../landscape/components/MetadataIcon'; import { MetadataIcon } from '../landscape/components/MetadataIcon';
import { JoinGroup } from '../landscape/components/JoinGroup'; import { JoinGroup } from '../landscape/components/JoinGroup';
@ -23,27 +22,12 @@ export function GroupLink(
const name = resource.slice(6); const name = resource.slice(6);
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null); const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const associations = useMetadataState(state => state.associations); const associations = useMetadataState(state => state.associations);
const { save, restore } = useVirtual();
const history = useHistory();
const joined = resource in associations.groups; const joined = resource in associations.groups;
const { save, restore } = useVirtual();
const { modal, showModal } = useModal({ const { modal, showModal } = useModal({
modal: modal: <JoinGroup api={api} autojoin={name} />
joined && preview ? (
<Box width="fit-content" p="4">
<GroupSummary
metadata={preview.metadata}
memberCount={preview.members}
channelCount={preview?.['channel-count']}
/>
</Box>
) : (
<JoinGroup
api={api}
autojoin={name}
/>
)
}); });
useEffect(() => { useEffect(() => {
@ -72,7 +56,9 @@ export function GroupLink(
alignItems="center" alignItems="center"
py="2" py="2"
pr="2" pr="2"
onClick={showModal} onClick={
joined ? () => history.push(`/~landscape/ship/${name}`) : showModal
}
cursor='pointer' cursor='pointer'
opacity={preview ? '1' : '0.6'} opacity={preview ? '1' : '0.6'}
> >

View File

@ -7,11 +7,11 @@ import {
Row, Row,
Button, Button,
Label, Label,
ErrorLabel, BaseInput,
BaseInput Text,
Icon
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { StorageState } from '~/types';
import useStorage from '~/logic/lib/useStorage'; import useStorage from '~/logic/lib/useStorage';
type ImageInputProps = Parameters<typeof Box>[0] & { type ImageInputProps = Parameters<typeof Box>[0] & {
@ -20,13 +20,100 @@ type ImageInputProps = Parameters<typeof Box>[0] & {
placeholder?: string; 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 { export function ImageInput(props: ImageInputProps): ReactElement {
const { id, label, caption, placeholder } = props; const { id, label, caption } = props;
const { uploadDefault, canUpload, uploading } = useStorage(); const { uploadDefault, canUpload, uploading } = useStorage();
const [field, meta, { setValue, setError }] = useField(id); const [field, meta, { setValue, setError }] = useField(id);
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const onImageUpload = useCallback(async () => { const onImageUpload = useCallback(async () => {
@ -43,10 +130,14 @@ export function ImageInput(props: ImageInputProps): ReactElement {
} }
}, [ref.current, uploadDefault, canUpload, setValue]); }, [ref.current, uploadDefault, canUpload, setValue]);
const onClick = useCallback(() => { const clickUploadButton = useCallback(() => {
ref.current?.click(); ref.current?.click();
}, [ref]); }, [ref]);
const clearEvt = useCallback(() => {
setValue('');
}, []);
return ( return (
<Box display="flex" flexDirection="column" {...props}> <Box display="flex" flexDirection="column" {...props}>
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
@ -55,25 +146,25 @@ export function ImageInput(props: ImageInputProps): ReactElement {
{caption} {caption}
</Label> </Label>
) : null} ) : null}
<Row mt="2" alignItems="flex-end"> <Row mt="2" alignItems="flex-end" position='relative' width='100%'>
<Input {prompt(field, uploading, meta, clickUploadButton)}
type={'text'} {clearButton(field, uploading, clearEvt)}
hasError={meta.touched && meta.error !== undefined} {uploadingStatus(uploading, meta)}
placeholder={placeholder} {errorRetry(meta, uploading, clickUploadButton)}
{...field} <Box background='white' borderRadius={2} width='100%'>
/> <Input
width='100%'
type={'text'}
hasError={meta.touched && meta.error !== undefined}
{...field}
/>
</Box>
{canUpload && ( {canUpload && (
<> <>
<Button <Button
type="button" display='none'
ml={1} onClick={clickUploadButton}
border={1} />
borderColor="lightGray"
onClick={onClick}
flexShrink={0}
>
{uploading ? 'Uploading' : 'Upload'}
</Button>
<BaseInput <BaseInput
style={{ display: 'none' }} style={{ display: 'none' }}
type="file" type="file"
@ -85,9 +176,6 @@ export function ImageInput(props: ImageInputProps): ReactElement {
</> </>
)} )}
</Row> </Row>
<ErrorLabel mt="2" hasError={Boolean(meta.touched && meta.error)}>
{meta.error}
</ErrorLabel>
</Box> </Box>
); );
} }

View File

@ -1,79 +1,329 @@
import React, { ReactElement, ReactNode } from 'react'; import React, { ReactElement, ReactNode, useCallback } from "react";
import { Text, Box, Icon, Row } from '@tlon/indigo-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 { cite, isDm } from "~/logic/lib/util";
import { MetadataUpdatePreview, JoinProgress, Invite, JoinRequest } from '@urbit/api'; import {
import { GroupSummary } from '~/views/landscape/components/GroupSummary'; MetadataUpdatePreview,
import { InviteSkeleton } from './InviteSkeleton'; joinProgress,
import { JoinSkeleton } from './JoinSkeleton'; JoinProgress,
import GlobalApi from '~/logic/api/global'; 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 { interface GroupInviteProps {
preview: MetadataUpdatePreview; preview?: MetadataUpdatePreview;
status?: JoinRequest; status?: JoinRequest;
app?: string;
uid?: string;
invite?: Invite; invite?: Invite;
resource: string; resource: string;
api: GlobalApi; api: GlobalApi;
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
} }
export function GroupInvite(props: GroupInviteProps): ReactElement { function Elbow(
const { resource, api, preview, invite, status, onAccept, onDecline } = props; props: { size?: number; color?: string } & PropFunc<typeof Box>
const { metadata, members } = props.preview; ) {
const { size = 12, color = "lightGray", ...rest } = props;
let inner: ReactNode = null; return (
let Outer: (p: { children: ReactNode }) => JSX.Element = p => ( <Box
<>{p.children}</> {...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) { if (preview) {
inner = ( const { title } = preview.metadata;
<Text mr="1"> const { members } = preview;
You are joining <Text fontWeight="medium">{metadata.title}</Text> return container(
</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 = (
<> <>
<Text mr="1" mono> <MetadataIcon height={4} width={4} metadata={preview.metadata} />
{cite(`~${invite!.ship}`)} <Text fontWeight="medium">{title}</Text>
<Text gray fontWeight="medium">
{members} Member{members > 1 ? "s" : ""}
</Text> </Text>
<Text mr="1">invited you to </Text>
<Text fontWeight="medium">{metadata.title}</Text>
</> </>
); );
} }
return (
<Outer> return container(
<Row py="1" alignItems="center"> <>
<Icon display="block" mr={2} icon="Bullet" color="blue" /> <Text whiteSpace="nowrap" textOverflow="ellipsis" ml="1px" mb="2px" mono>
{inner} {cite(ship)}/{name}
</Row> </Text>
<Box px="4"> </>
<GroupSummary );
gray }
metadata={metadata}
memberCount={members} function InviteStatus(props: { status?: JoinRequest }) {
channelCount={preview?.['channel-count']} const { status } = props;
/>
</Box> if (!status) {
</Outer> 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, JoinRequests,
Groups, Groups,
Associations, Associations,
JoinRequest,
} from "@urbit/api"; } from "@urbit/api";
import { Invite } from "@urbit/api/invite"; 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 GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
import { GroupInvite } from "./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 { interface InviteItemProps {
invite?: Invite; invite?: Invite;
resource: string; resource: string;
pendingJoin?: string; pendingJoin?: JoinRequest;
app?: string; app?: string;
uid?: string; uid?: string;
api: GlobalApi; 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) { export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null); const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { pendingJoin, invite, resource, uid, app, api } = props; 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(() => { useEffect(() => {
if (!app || app === "groups") { if (!app || app === "groups") {
@ -132,86 +45,17 @@ export function InviteItem(props: InviteItemProps) {
return null; return null;
} }
if (preview) { return (
return ( <GroupInvite
<GroupInvite resource={resource}
resource={resource} api={api}
api={api} preview={preview}
preview={preview} invite={invite}
invite={invite} status={pendingJoin}
status={pendingJoin} uid={uid}
{...handlers} app={app}
/> />
); );
} 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 null;
}
export default InviteItem; export default InviteItem;

View File

@ -3,10 +3,10 @@ import _ from 'lodash';
import { Text, Box } from '@tlon/indigo-react'; import { Text, Box } from '@tlon/indigo-react';
import { Contact, Contacts, Content, Group } from '@urbit/api'; import { Contact, Contacts, Content, Group } from '@urbit/api';
import RichText from '~/views/components/RichText'; 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 ProfileOverlay from '~/views/components/ProfileOverlay';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import useContactState from '~/logic/state/contact'; import useContactState, {useContact} from '~/logic/state/contact';
import {referenceToPermalink} from '~/logic/lib/permalinks'; import {referenceToPermalink} from '~/logic/lib/permalinks';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
@ -40,46 +40,28 @@ export function MentionText(props: MentionTextProps) {
} }
export function Mention(props: { export function Mention(props: {
contact: Contact;
group: Group;
scrollWindow?: HTMLElement;
ship: string; ship: string;
first?: Boolean; first?: Boolean;
api: any; api: any;
}) { }) {
const { ship, scrollWindow, first, api, ...rest } = props; const { ship, first, api, ...rest } = props;
let { contact } = props; const contact = useContact(`~${deSig(ship)}`);
const contacts = useContactState(state => state.contacts);
contact = contact?.color ? contact : contacts?.[`~${ship}`];
const history = useHistory();
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
const name = showNickname ? contact?.nickname : cite(ship); const name = showNickname ? contact?.nickname : cite(ship);
const group = props.group ?? { hidden: true };
const [showOverlay, setShowOverlay] = useState(false);
const toggleOverlay = useCallback(() => {
setShowOverlay((value) => !value);
}, [showOverlay]);
return ( return (
<Box position='relative' display='inline-block' cursor='pointer' {...rest}> <ProfileOverlay ship={ship} api={api} display="inline">
<ProfileOverlay <Text
ship={ship} marginLeft={first? 0 : 1}
api={api} marginRight={1}
> px={1}
<Text bg='washedBlue'
onClick={() => toggleOverlay()} color='blue'
marginLeft={first? 0 : 1} fontSize={showNickname ? 1 : 0}
marginRight={1} mono={!showNickname}
px={1} >
bg='washedBlue' {name}
color='blue' </Text>
fontSize={showNickname ? 1 : 0} </ProfileOverlay>
mono={!showNickname}
>
{name}
</Text>
</ProfileOverlay>
</Box>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { PureComponent, useCallback, useEffect, useRef, useState, useMemo } from 'react'; import React, { PureComponent, useCallback, useEffect, useRef, useState, useMemo, ReactNode } from 'react';
import { Contact, Group, uxToHex } from '@urbit/api'; import { Contact, Group, uxToHex } from '@urbit/api';
import _ from 'lodash'; import _ from 'lodash';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
@ -40,6 +40,7 @@ const FixedOverlay = styled(Col)`
type ProfileOverlayProps = BoxProps & { type ProfileOverlayProps = BoxProps & {
ship: string; ship: string;
api: any; api: any;
children: ReactNode;
}; };
const ProfileOverlay = (props: ProfileOverlayProps) => { const ProfileOverlay = (props: ProfileOverlayProps) => {
@ -150,6 +151,16 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
padding={3} padding={3}
justifyContent='center' justifyContent='center'
> >
<Row color='black' padding={3} position='absolute' top={0} left={0}>
{!isOwn && (
<Icon
icon='Chat'
size={16}
cursor='pointer'
onClick={() => history.push(`/~landscape/dm/${ship}`)}
/>
)}
</Row>
<Box <Box
alignSelf='center' alignSelf='center'
height='72px' height='72px'

View File

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

View File

@ -157,7 +157,7 @@ export function ShipSearch<I extends string, V extends Value<I>>(
setFieldValue(name(), newValue); setFieldValue(name(), newValue);
}; };
const error = _.compact(errors[id] as string[]); const error = _.compact((_.isString(errors[id]) ? [errors[id]] : errors[id] as string[]) as any);
const isExact = useCallback((s: string) => { const isExact = useCallback((s: string) => {
const ship = `~${deSig(s)}`; const ship = `~${deSig(s)}`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,82 @@
import React from "react";
import { Post, ReferenceContent } from "@urbit/api";
import { Box } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global";
import TextContent from "./content/text";
import CodeContent from "./content/code";
import RemoteContent from "~/views/components/RemoteContent";
import { Mention } from "~/views/components/MentionText";
import { PermalinkEmbed } from "~/views/apps/permalinks/embed";
import { referenceToPermalink } from "~/logic/lib/permalinks";
import { PropFunc } from "~/types";
function GraphContentWideInner(
props: {
transcluded?: number;
post: Post;
api: GlobalApi;
showOurContact: boolean;
} & PropFunc<typeof Box>
) {
const { post, transcluded = 0, showOurContact, api, ...rest } = props;
return (
<Box {...rest}>
{post.contents.map((content, i) => {
switch (Object.keys(content)[0]) {
case "text":
return (
<TextContent
key={i}
api={api}
fontSize={1}
lineHeight={"20px"}
content={content}
/>
);
case "code":
return <CodeContent key={i} content={content} />;
case "reference":
const { link } = referenceToPermalink(content as ReferenceContent);
return (
<PermalinkEmbed
link={link}
api={api}
transcluded={transcluded}
showOurContact={showOurContact}
/>
);
case "url":
return (
<Box
key={i}
flexShrink={0}
fontSize={1}
lineHeight="20px"
color="black"
width="fit-content"
maxWidth="min(500px, 100%)"
>
<RemoteContent key={content.url} url={content.url} />
</Box>
);
case "mention":
const first = (i) => i === 0;
return (
<Mention
key={i}
first={first(i)}
ship={content.mention}
api={api}
/>
);
default:
return null;
}
})}
</Box>
);
}
export const GraphContentWide = React.memo(GraphContentWideInner);

View File

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

View File

@ -23,7 +23,7 @@ export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): R
anchorRef anchorRef
); );
return ( return (
<Col {...rest} ref={anchorRef} gapY="4"> <Col {...rest} ref={anchorRef} gapY="4" maxWidth={['100%', '288px']}>
<Row gapX="2" width="100%"> <Row gapX="2" width="100%">
<MetadataIcon <MetadataIcon
width="40px" width="40px"

View File

@ -54,6 +54,7 @@ function GroupFeed(props) {
return; return;
} }
api.graph.getNewest(graphResource.ship, graphResource.name, 100); api.graph.getNewest(graphResource.ship, graphResource.name, 100);
api.hark.markCountAsRead(association, '/', 'post');
}, [graphPath]); }, [graphPath]);
if (!graphPath) { if (!graphPath) {

View File

@ -1,30 +1,35 @@
import React from 'react'; import React from 'react';
import { Col } from '@tlon/indigo-react'; import { Col, Box } from '@tlon/indigo-react';
import { MentionText } from '~/views/components/MentionText'; import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
import useContactState from '~/logic/state/contact'; import styled from 'styled-components';
const TruncatedBox = styled(Col)`
display: -webkit-box;
-webkit-line-clamp: ${p => p.truncate ?? 'unset'};
-webkit-box-orient: vertical;
`;
export function PostContent(props) { export function PostContent(props) {
const { post, isParent, api, isReply } = props; const { post, isParent, api, isReply } = props;
const contacts = useContactState(state => state.contacts);
return ( return (
<Col <TruncatedBox
display="-webkit-box"
width="100%" width="100%"
pl="2" px="2"
pr="2" pb="2"
pb={isParent || isReply ? "0" : "2"} truncate={isParent ? null : 8}
maxHeight={ isParent ? "none" : "300px" }
textOverflow="ellipsis" textOverflow="ellipsis"
overflow="hidden" overflow="hidden"
display="inline-block"> >
<MentionText <GraphContentWide
contacts={contacts}
content={post.contents}
api={api}
transcluded={0} transcluded={0}
post={post}
api={api}
showOurContact
/> />
</Col> </TruncatedBox>
); );
} }

View File

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

View File

@ -81,6 +81,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
if(group === TUTORIAL_GROUP_RESOURCE) { if(group === TUTORIAL_GROUP_RESOURCE) {
await api.settings.putEntry('tutorial', 'joined', Date.now()); await api.settings.putEntry('tutorial', 'joined', Date.now());
} }
if (group in groups) {
return history.push(`/~landscape${group}`);
}
await api.groups.join(ship, name); await api.groups.join(ship, name);
try { try {
await waiter((p) => { await waiter((p) => {
@ -111,6 +114,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => { async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
const [ship, name] = values.group.split('/'); const [ship, name] = values.group.split('/');
const path = `/ship/${ship}/${name}`; const path = `/ship/${ship}/${name}`;
if (path in groups) {
return history.push(`/~landscape${path}`);
}
// skip if it's unmanaged // skip if it's unmanaged
try { try {
const prev = await api.metadata.preview(path); const prev = await api.metadata.preview(path);
@ -151,47 +157,50 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
</StatelessAsyncButton> </StatelessAsyncButton>
</Col> </Col>
) : preview ? ( ) : preview ? (
<GroupSummary <>
metadata={preview.metadata} <GroupSummary
memberCount={preview?.members} metadata={preview.metadata}
channelCount={preview?.['channel-count']} memberCount={preview?.members}
> channelCount={preview?.['channel-count']}
{ Object.keys(preview.channels).length > 0 && ( >
<Col { Object.keys(preview.channels).length > 0 && (
gapY="2" <Col
p="2" gapY="2"
borderRadius="2" p="2"
border="1" borderRadius="2"
borderColor="washedGray" border="1"
bg="washedBlue" borderColor="washedGray"
maxHeight="300px" bg="washedBlue"
overflowY="auto" maxHeight="300px"
> overflowY="auto"
<Text gray fontSize="1"> >
Channels <Text gray fontSize="1">
</Text> Channels
<Box width="100%" flexShrink="0"> </Text>
{Object.values(preview.channels).map(({ metadata }: any) => ( <Box width="100%" flexShrink="0">
<Row width="100%"> {Object.values(preview.channels).map(({ metadata }: any) => (
<Icon <Row width="100%">
mr="2" <Icon
color="blue" mr="2"
icon={getModuleIcon(metadata?.config?.graph) as any} color="blue"
/> icon={getModuleIcon(metadata?.config?.graph) as any}
<Text color="blue">{metadata.title} </Text> />
</Row> <Text color="blue">{metadata.title} </Text>
))} </Row>
</Box> ))}
</Col> </Box>
)} </Col>
)}
</GroupSummary>
<StatelessAsyncButton <StatelessAsyncButton
marginTop={3}
primary primary
name="join" name="join"
onClick={() => onConfirm(preview.group)} onClick={() => onConfirm(preview.group)}
> >
Join {preview.metadata.title} Join {preview.metadata.title}
</StatelessAsyncButton> </StatelessAsyncButton>
</GroupSummary> </>
) : ( ) : (
<Col width="100%" gapY="4"> <Col width="100%" gapY="4">
<Formik <Formik

View File

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

View File

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

View File

@ -136,7 +136,7 @@ export function SidebarItem(props: {
{DM ? img : ( {DM ? img : (
<Icon <Icon
display="block" display="block"
color={isSynced ? 'black' : 'gray'} color={isSynced ? 'black' : 'lightGray'}
icon={getModuleIcon(mod) as any} icon={getModuleIcon(mod) as any}
/> />
) )

View File

@ -24,6 +24,7 @@ import { Workspace } from '~/types/workspace';
import useGroupState from '~/logic/state/group'; import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import {IS_SAFARI} from '~/logic/lib/platform'; import {IS_SAFARI} from '~/logic/lib/platform';
import useHarkState from '~/logic/state/hark';
export function SidebarListHeader(props: { export function SidebarListHeader(props: {
api: GlobalApi; api: GlobalApi;
@ -54,15 +55,15 @@ export function SidebarListHeader(props: {
const noun = (props.workspace?.type === 'messages') ? 'Messages' : 'Channels'; const noun = (props.workspace?.type === 'messages') ? 'Messages' : 'Channels';
const isFeedEnabled = const feedPath = metadata?.config?.group?.resource;
metadata &&
metadata.config && const unreadCount = useHarkState(
metadata.config.group && s => s.unreads?.graph?.[feedPath ?? ""]?.["/"]?.unreads as number ?? 0
'resource' in metadata.config.group; );
return ( return (
<Box> <Box>
{( isFeedEnabled ) ? ( {( !!feedPath) ? (
<Row <Row
flexShrink="0" flexShrink="0"
alignItems="center" alignItems="center"
@ -90,6 +91,9 @@ export function SidebarListHeader(props: {
<Text> <Text>
Group Feed Group Feed
</Text> </Text>
<Text mr="1" color="blue">
{ unreadCount > 0 && unreadCount}
</Text>
</Row> </Row>
) : null ) : null
} }

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

@ -65,3 +65,13 @@
*/ */
u3_noun u3_noun
u3s_cue_atom(u3_atom a); u3s_cue_atom(u3_atom a);
/* u3s_sift_ud_bytes: parse @ud.
*/
u3_weak
u3s_sift_ud_bytes(c3_w len_w, c3_y* byt_y);
/* u3s_sift_ud: parse @ud.
*/
u3_weak
u3s_sift_ud(u3_atom a);

View File

@ -5,49 +5,16 @@
#include <ctype.h> #include <ctype.h>
/* functions static inline u3_noun
*/ _parse_ud(u3_noun a)
u3_noun {
_parse_ud(u3_noun txt) { u3_weak pro;
c3_c* c = u3a_string(txt);
// First character must represent a digit if ( u3_none == (pro = u3s_sift_ud(u3x_atom(a))) ) {
c3_c* cur = c; return u3_nul;
if (cur[0] > '9' || cur[0] < '0') {
u3a_free(c);
return u3_none;
}
u3_atom total = cur[0] - '0';
cur++;
int since_last_period = 0;
while (cur[0] != 0) {
since_last_period++;
if (cur[0] == '.') {
since_last_period = 0;
cur++;
continue;
}
if (cur[0] > '9' || cur[0] < '0') {
u3a_free(c);
u3z(total);
return u3_none;
}
total = u3ka_mul(total, 10);
total = u3ka_add(total, cur[0] - '0');
cur++;
if (since_last_period > 3) {
u3a_free(c);
u3z(total);
return u3_none;
}
} }
u3a_free(c); return u3nc(u3_nul, pro);
return u3nc(0, total);
} }
static static

View File

@ -313,12 +313,17 @@ u3i_word(c3_w dat_w)
u3_atom u3_atom
u3i_chub(c3_d dat_d) u3i_chub(c3_d dat_d)
{ {
c3_w dat_w[2] = { if ( c3y == u3a_is_cat(dat_d) ) {
dat_d & 0xffffffffULL, return (u3_atom)dat_d;
dat_d >> 32 }
}; else {
c3_w dat_w[2] = {
dat_d & 0xffffffffULL,
dat_d >> 32
};
return u3i_words(2, dat_w); return u3i_words(2, dat_w);
}
} }
/* u3i_bytes(): Copy [a] bytes from [b] to an LSB first atom. /* u3i_bytes(): Copy [a] bytes from [b] to an LSB first atom.

View File

@ -98,28 +98,22 @@ _cm_punt(u3_noun tax)
} }
#endif #endif
static void _write(int fd, const void *buf, size_t count)
{
if (count != write(fd, buf, count)){
u3l_log("write failed\r\n");
c3_assert(0);
}
}
/* _cm_emergency(): write emergency text to stderr, never failing. /* _cm_emergency(): write emergency text to stderr, never failing.
*/ */
static void static void
_cm_emergency(c3_c* cap_c, c3_l sig_l) _cm_emergency(c3_c* cap_c, c3_l sig_l)
{ {
_write(2, "\r\n", 2); c3_i ret_i;
_write(2, cap_c, strlen(cap_c));
ret_i = write(2, "\r\n", 2);
ret_i = write(2, cap_c, strlen(cap_c));
if ( sig_l ) { if ( sig_l ) {
_write(2, ": ", 2); ret_i = write(2, ": ", 2);
_write(2, &sig_l, 4); ret_i = write(2, &sig_l, 4);
} }
_write(2, "\r\n", 2);
ret_i = write(2, "\r\n", 2);
} }
static void _cm_overflow(void *arg1, void *arg2, void *arg3) static void _cm_overflow(void *arg1, void *arg2, void *arg3)
@ -127,7 +121,7 @@ static void _cm_overflow(void *arg1, void *arg2, void *arg3)
(void)(arg1); (void)(arg1);
(void)(arg2); (void)(arg2);
(void)(arg3); (void)(arg3);
siglongjmp(u3_Signal, c3__over); u3m_signal(c3__over);
} }
/* _cm_signal_handle(): handle a signal in general. /* _cm_signal_handle(): handle a signal in general.
@ -139,7 +133,7 @@ _cm_signal_handle(c3_l sig_l)
sigsegv_leave_handler(_cm_overflow, NULL, NULL, NULL); sigsegv_leave_handler(_cm_overflow, NULL, NULL, NULL);
} }
else { else {
siglongjmp(u3_Signal, sig_l); u3m_signal(sig_l);
} }
} }
@ -661,7 +655,10 @@ u3m_dump(void)
c3_i c3_i
u3m_bail(u3_noun how) u3m_bail(u3_noun how)
{ {
if ( (c3__exit == how) && (u3R == &u3H->rod_u) ) { if ( &(u3H->rod_u) == u3R ) {
// XX set exit code
//
fprintf(stderr, "home: bailing out\r\n");
abort(); abort();
} }
@ -688,8 +685,9 @@ u3m_bail(u3_noun how)
// //
switch ( how ) { switch ( how ) {
case c3__foul: case c3__foul:
case c3__meme:
case c3__oops: { case c3__oops: {
// XX set exit code
//
fprintf(stderr, "bailing out\r\n"); fprintf(stderr, "bailing out\r\n");
abort(); abort();
} }
@ -700,6 +698,9 @@ u3m_bail(u3_noun how)
// choice but to use the signal process; and we require the flat // choice but to use the signal process; and we require the flat
// form of how. // form of how.
// //
// XX JB: these seem unrecoverable, at least wrt memory management,
// so they've been disabled above for now
//
c3_assert(_(u3a_is_cat(how))); c3_assert(_(u3a_is_cat(how)));
u3m_signal(how); u3m_signal(how);
} }

View File

@ -1205,16 +1205,11 @@ u3r_mp(mpz_t a_mp,
else { else {
u3a_atom* b_u = u3a_to_ptr(b); u3a_atom* b_u = u3a_to_ptr(b);
c3_w len_w = b_u->len_w; c3_w len_w = b_u->len_w;
c3_d bit_d = (c3_d)len_w << 5;
// avoid reallocation on import, if possible // avoid reallocation on import, if possible
// //
if ( (len_w >> 27) ) { mpz_init2(a_mp, (c3_w)c3_min(bit_d, UINT32_MAX));
mpz_init(a_mp);
}
else {
mpz_init2(a_mp, len_w << 5);
}
mpz_import(a_mp, len_w, -1, sizeof(c3_w), 0, 0, b_u->buf_w); mpz_import(a_mp, len_w, -1, sizeof(c3_w), 0, 0, b_u->buf_w);
} }
} }

View File

@ -856,3 +856,128 @@ u3s_cue_atom(u3_atom a)
return u3s_cue_bytes((c3_d)len_w, byt_y); return u3s_cue_bytes((c3_d)len_w, byt_y);
} }
#define DIGIT(a) ( ((a) >= '0') && ((a) <= '9') )
#define BLOCK(a) ( ('.' == (a)[0]) \
&& DIGIT(a[1]) \
&& DIGIT(a[2]) \
&& DIGIT(a[3]) )
/* u3s_sift_ud_bytes: parse @ud
*/
u3_weak
u3s_sift_ud_bytes(c3_w len_w, c3_y* byt_y)
{
c3_y num_y = len_w % 4; // leading digits length
c3_s val_s = 0; // leading digits value
// +ape:ag: just 0
//
if ( !len_w ) return u3_none;
if ( '0' == *byt_y ) return ( 1 == len_w ) ? (u3_noun)0 : u3_none;
// +ted:ab: leading nonzero (checked above), plus up to 2 digits
//
#define NEXT() do { \
if ( !DIGIT(*byt_y) ) return u3_none; \
val_s *= 10; \
val_s += *byt_y++ - '0'; \
} while (0)
switch ( num_y ) {
case 3: NEXT();
case 2: NEXT();
case 1: NEXT(); break;
case 0: return u3_none;
}
#undef NEXT
len_w -= num_y;
// +tid:ab: dot-prefixed 3-digit blocks
//
// avoid gmp allocation if possible
// - 19 decimal digits fit in 64 bits
// - 18 digits is 24 bytes with separators
//
if ( ((1 == num_y) && (24 >= len_w))
|| (20 >= len_w) )
{
c3_d val_d = val_s;
while ( len_w ) {
if ( !BLOCK(byt_y) ) return u3_none;
byt_y++;
val_d *= 10;
val_d += *byt_y++ - '0';
val_d *= 10;
val_d += *byt_y++ - '0';
val_d *= 10;
val_d += *byt_y++ - '0';
len_w -= 4;
}
return u3i_chub(val_d);
}
{
// avoid gmp realloc if possible
//
mpz_t a_mp;
{
c3_d bit_d = (c3_d)(len_w / 4) * 10;
mpz_init2(a_mp, (c3_w)c3_min(bit_d, UINT32_MAX));
mpz_set_ui(a_mp, val_s);
}
while ( len_w ) {
if ( !BLOCK(byt_y) ) {
mpz_clear(a_mp);
return u3_none;
}
byt_y++;
val_s = *byt_y++ - '0';
val_s *= 10;
val_s += *byt_y++ - '0';
val_s *= 10;
val_s += *byt_y++ - '0';
mpz_mul_ui(a_mp, a_mp, 1000);
mpz_add_ui(a_mp, a_mp, val_s);
len_w -= 4;
}
return u3i_mp(a_mp);
}
}
#undef BLOCK
#undef DIGIT
/* u3s_sift_ud: parse @ud.
*/
u3_weak
u3s_sift_ud(u3_atom a)
{
c3_w len_w = u3r_met(3, a);
c3_y* byt_y;
// XX assumes little-endian
//
if ( c3y == u3a_is_cat(a) ) {
byt_y = (c3_y*)&a;
}
else {
u3a_atom* vat_u = u3a_to_ptr(a);
byt_y = (c3_y*)vat_u->buf_w;
}
return u3s_sift_ud_bytes(len_w, byt_y);
}

View File

@ -9,6 +9,108 @@ _setup(void)
u3m_pave(c3y); u3m_pave(c3y);
} }
static inline c3_i
_ud_good(c3_w num_w, const c3_c* num_c)
{
u3_weak out;
if ( num_w != (out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c)) ) {
if ( u3_none == out ) {
fprintf(stderr, "sift_ud: %s fail; expected %u\r\n", num_c, num_w);
}
else {
fprintf(stderr, "sift_ud: %s wrong; expected %u: actual %u\r\n", num_c, num_w, out);
}
return 0;
}
return 1;
}
static inline c3_i
_ud_fail(const c3_c* num_c)
{
u3_weak out;
if ( u3_none != (out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c)) ) {
u3m_p("out", out);
fprintf(stderr, "sift_ud: %s expected fail\r\n", num_c);
return 0;
}
return 1;
}
static c3_i
_test_sift_ud(void)
{
c3_i ret_i = 1;
ret_i &= _ud_good(0, "0");
ret_i &= _ud_good(1, "1");
ret_i &= _ud_good(12, "12");
ret_i &= _ud_good(123, "123");
ret_i &= _ud_good(1234, "1.234");
ret_i &= _ud_good(12345, "12.345");
ret_i &= _ud_good(123456, "123.456");
ret_i &= _ud_good(1234567, "1.234.567");
ret_i &= _ud_good(12345678, "12.345.678");
ret_i &= _ud_good(123456789, "123.456.789");
ret_i &= _ud_good(100000000, "100.000.000");
ret_i &= _ud_good(101101101, "101.101.101");
ret_i &= _ud_good(201201201, "201.201.201");
ret_i &= _ud_good(302201100, "302.201.100");
ret_i &= _ud_fail("01");
ret_i &= _ud_fail("02");
ret_i &= _ud_fail("003");
ret_i &= _ud_fail("1234");
ret_i &= _ud_fail("1234.5");
ret_i &= _ud_fail("1234.567.8");
ret_i &= _ud_fail("1234.56..78.");
ret_i &= _ud_fail("123.45a");
ret_i &= _ud_fail(".123.456");
{
c3_c* num_c = "4.294.967.296";
u3_weak out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c);
u3_atom pro = u3qc_bex(32);
if ( u3_none == out ) {
fprintf(stderr, "sift_ud: (bex 32) fail\r\n");
ret_i = 0;
}
if ( c3n == u3r_sing(pro, out) ) {
u3m_p("out", out);
fprintf(stderr, "sift_ud: (bex 32) wrong\r\n");
ret_i = 0;
}
u3z(out); u3z(pro);
}
{
c3_c* num_c = "340.282.366.920.938.463.463.374.607.431.768.211.456";
u3_weak out = u3s_sift_ud_bytes(strlen(num_c), (c3_y*)num_c);
u3_atom pro = u3qc_bex(128);
if ( u3_none == out ) {
fprintf(stderr, "sift_ud: (bex 128) fail\r\n");
ret_i = 0;
}
if ( c3n == u3r_sing(pro, out) ) {
u3m_p("out", out);
fprintf(stderr, "sift_ud: (bex 128) wrong\r\n");
ret_i = 0;
}
u3z(out); u3z(pro);
}
return ret_i;
}
static c3_i static c3_i
_test_en_base16(void) _test_en_base16(void)
{ {
@ -400,6 +502,11 @@ _test_jets(void)
{ {
c3_i ret_i = 1; c3_i ret_i = 1;
if ( !_test_sift_ud() ) {
fprintf(stderr, "test jets: sift_ud: failed\r\n");
ret_i = 0;
}
if ( !_test_base16() ) { if ( !_test_base16() ) {
fprintf(stderr, "test jets: base16: failed\r\n"); fprintf(stderr, "test jets: base16: failed\r\n");
ret_i = 0; ret_i = 0;

View File

@ -20,7 +20,8 @@
u3_csat_init = 0, // initialized u3_csat_init = 0, // initialized
u3_csat_addr = 1, // address resolution begun u3_csat_addr = 1, // address resolution begun
u3_csat_quit = 2, // cancellation requested 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_csat;
/* u3_cres: response to http client. /* u3_cres: response to http client.
@ -35,26 +36,26 @@
/* u3_creq: outgoing http request. /* u3_creq: outgoing http request.
*/ */
typedef struct _u3_creq { // client 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 h2o_http1client_t* cli_u; // h2o client
u3_csat sat_e; // connection state u3_csat sat_e; // connection state
c3_o sec; // yes == https c3_o sec; // yes == https
c3_w ipf_w; // IP c3_w ipf_w; // IP
c3_c* ipf_c; // IP (string) c3_c* ipf_c; // IP (string)
c3_c* hot_c; // host c3_c* hot_c; // host
c3_s por_s; // port c3_s por_s; // port
c3_c* por_c; // port (string) c3_c* por_c; // port (string)
c3_c* met_c; // method c3_c* met_c; // method
c3_c* url_c; // url c3_c* url_c; // url
u3_hhed* hed_u; // headers u3_hhed* hed_u; // headers
u3_hbod* bod_u; // body u3_hbod* bod_u; // body
u3_hbod* rub_u; // exit of send queue u3_hbod* rub_u; // exit of send queue
u3_hbod* bur_u; // entry of send queue u3_hbod* bur_u; // entry of send queue
h2o_iovec_t* vec_u; // send-buffer array h2o_iovec_t* vec_u; // send-buffer array
u3_cres* res_u; // nascent response u3_cres* res_u; // nascent response
struct _u3_creq* nex_u; // next in list struct _u3_creq* nex_u; // next in list
struct _u3_creq* pre_u; // previous in list struct _u3_creq* pre_u; // previous in list
struct _u3_cttp* ctp_u; // cttp backpointer struct _u3_cttp* ctp_u; // cttp backpointer
} u3_creq; } u3_creq;
/* u3_cttp: http client. /* u3_cttp: http client.
@ -559,13 +560,18 @@ _cttp_creq_new(u3_cttp* ctp_u, c3_l num_l, u3_noun hes)
return 0; return 0;
} }
// Parse the url out of the new style url passed to us. // parse the url out of the new style url passed to us.
//
u3_noun unit_pul = u3do("de-purl:html", u3k(url)); u3_noun unit_pul = u3do("de-purl:html", u3k(url));
if (c3n == u3r_du(unit_pul)) {
u3l_log("cttp: url parsing failed\n"); if ( c3n == u3r_du(unit_pul) ) {
c3_c* url_c = u3r_string(url);
u3l_log("cttp: unable to parse url:\n %s\n", url_c);
c3_free(url_c);
u3z(hes); u3z(hes);
return 0; return 0;
} }
u3_noun pul = u3t(unit_pul); u3_noun pul = u3t(unit_pul);
u3_noun hat = u3h(pul); // +hart u3_noun hat = u3h(pul); // +hart
@ -817,20 +823,33 @@ _cttp_creq_on_head(h2o_http1client_t* cli_u, const c3_c* err_c, c3_i ver_i,
*/ */
static h2o_http1client_head_cb static h2o_http1client_head_cb
_cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c, _cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
h2o_iovec_t** vec_p, size_t* vec_t, c3_i* hed_i) h2o_iovec_t** vec_u, size_t* vec_i, c3_i* hed_i)
{ {
u3_creq* ceq_u = (u3_creq *)cli_u->data; u3_creq* ceq_u = (u3_creq *)cli_u->data;
if ( 0 != err_c ) { 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; return 0;
} }
// serialize request (populate rub_u)
//
_cttp_creq_fire(ceq_u);
{ {
c3_w len_w; c3_w len_w;
ceq_u->vec_u = _cttp_bods_to_vec(ceq_u->rub_u, &len_w); ceq_u->vec_u = _cttp_bods_to_vec(ceq_u->rub_u, &len_w);
*vec_t = len_w;
*vec_p = ceq_u->vec_u; *vec_i = len_w;
*vec_u = ceq_u->vec_u;
*hed_i = (0 == strcmp(ceq_u->met_c, "HEAD")); *hed_i = (0 == strcmp(ceq_u->met_c, "HEAD"));
} }
@ -842,24 +861,43 @@ _cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
static void static void
_cttp_creq_connect(u3_creq* ceq_u) _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); c3_assert( ceq_u->ipf_c );
h2o_iovec_t ipf_u = h2o_iovec_init(ceq_u->ipf_c, strlen(ceq_u->ipf_c)); // connect by ip/port, avoiding synchronous getaddrinfo()
c3_s por_s = ceq_u->por_s ? ceq_u->por_s : //
( c3y == ceq_u->sec ) ? 443 : 80; {
h2o_iovec_t ipf_u = h2o_iovec_init(ceq_u->ipf_c, strlen(ceq_u->ipf_c));
c3_t tls_t = ( c3y == ceq_u->sec );
c3_s por_s = ( ceq_u->por_s )
? ceq_u->por_s
: ( tls_t ) ? 443 : 80;
// connect by IP h2o_http1client_connect(&ceq_u->cli_u, ceq_u, &ceq_u->ctp_u->ctx_u,
h2o_http1client_connect(&ceq_u->cli_u, ceq_u, &ceq_u->ctp_u->ctx_u, ipf_u, ipf_u, por_s, tls_t, _cttp_creq_on_connect);
por_s, c3y == ceq_u->sec, _cttp_creq_on_connect);
// 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);
} }
_cttp_creq_fire(ceq_u); // 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 /* _cttp_creq_resolve_cb(): cb upon IP address resolution
@ -882,7 +920,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_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->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); _cttp_creq_connect(ceq_u);
} }
@ -926,7 +964,7 @@ static void
_cttp_creq_start(u3_creq* ceq_u) _cttp_creq_start(u3_creq* ceq_u)
{ {
if ( ceq_u->ipf_c ) { if ( ceq_u->ipf_c ) {
ceq_u->sat_e = u3_csat_ripe; ceq_u->sat_e = u3_csat_conn;
_cttp_creq_connect(ceq_u); _cttp_creq_connect(ceq_u);
} else { } else {
ceq_u->sat_e = u3_csat_addr; ceq_u->sat_e = u3_csat_addr;
@ -981,7 +1019,6 @@ _cttp_ef_http_client(u3_cttp* ctp_u, u3_noun tag, u3_noun dat)
ret_o = c3y; ret_o = c3y;
} }
else { else {
u3l_log("cttp: strange request (unparsable url)\n");
ret_o = c3n; ret_o = c3n;
} }
} }

Some files were not shown because too many files have changed in this diff Show More