mirror of
https://github.com/urbit/shrub.git
synced 2024-12-19 00:13:12 +03:00
Merge branch 'master' into la/ref-trans-graph
This commit is contained in:
commit
d5ed85efa8
@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:accbadc701471f6b071ed286164a0bf4d3f8a2e64cfaea9019e123bd9edca569
|
||||
size 10292454
|
||||
oid sha256:f6b5e33e573818120051651c1182163527edbbe0dff0eb6591e12a55cfccb273
|
||||
size 10486101
|
||||
|
@ -1,10 +1,24 @@
|
||||
{ urbit, libcap, coreutils, bashInteractive, dockerTools, writeScriptBin, amesPort ? 34343 }:
|
||||
{ urbit, curl, libcap, coreutils, bashInteractive, dockerTools, writeScriptBin, amesPort ? 34343 }:
|
||||
let
|
||||
startUrbit = writeScriptBin "start-urbit" ''
|
||||
#!${bashInteractive}/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
# set defaults
|
||||
amesPort=${toString amesPort}
|
||||
|
||||
# check args
|
||||
for i in "$@"
|
||||
do
|
||||
case $i in
|
||||
-p=*|--port=*)
|
||||
amesPort="''${i#*=}"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# If the container is not started with the `-i` flag
|
||||
# then STDIN will be closed and we need to start
|
||||
# Urbit/vere with the `-t` flag.
|
||||
@ -23,7 +37,7 @@ let
|
||||
mv $keyname /tmp
|
||||
|
||||
# Boot urbit with the key, exit when done booting
|
||||
urbit $ttyflag -w $(basename $keyname .key) -k /tmp/$keyname -c $(basename $keyname .key) -p ${toString amesPort} -x
|
||||
urbit $ttyflag -w $(basename $keyname .key) -k /tmp/$keyname -c $(basename $keyname .key) -p $amesPort -x
|
||||
|
||||
# Remove the keyfile for security
|
||||
rm /tmp/$keyname
|
||||
@ -34,7 +48,7 @@ let
|
||||
cometname=''${comets[0]}
|
||||
rm *.comet
|
||||
|
||||
urbit $ttyflag -c $(basename $cometname .comet) -p ${toString amesPort} -x
|
||||
urbit $ttyflag -c $(basename $cometname .comet) -p $amesPort -x
|
||||
fi
|
||||
|
||||
# Find the first directory and start urbit with the ship therein
|
||||
@ -42,14 +56,44 @@ let
|
||||
dirs=( $dirnames )
|
||||
dirname=''${dirnames[0]}
|
||||
|
||||
urbit $ttyflag -p ${toString amesPort} $dirname
|
||||
exec urbit $ttyflag -p $amesPort $dirname
|
||||
'';
|
||||
|
||||
getUrbitCode = writeScriptBin "get-urbit-code" ''
|
||||
#!${bashInteractive}/bin/bash
|
||||
|
||||
raw=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{ "source": { "dojo": "+code" }, "sink": { "stdout": null } }' \
|
||||
http://127.0.0.1:12321)
|
||||
|
||||
# trim \n" from the end
|
||||
trim="''${raw%\\n\"}"
|
||||
|
||||
# trim " from the start
|
||||
code="''${trim#\"}"
|
||||
|
||||
echo "$code"
|
||||
'';
|
||||
|
||||
resetUrbitCode = writeScriptBin "reset-urbit-code" ''
|
||||
#!${bashInteractive}/bin/bash
|
||||
|
||||
curl=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{ "source": { "dojo": "+hood/code %reset" }, "sink": { "app": "hood" } }' \
|
||||
http://127.0.0.1:12321)
|
||||
|
||||
if [[ $? -eq 0 ]]
|
||||
then
|
||||
echo "OK"
|
||||
else
|
||||
echo "Curl error: $?"
|
||||
fi
|
||||
'';
|
||||
|
||||
|
||||
in dockerTools.buildImage {
|
||||
name = "urbit";
|
||||
tag = "v${urbit.version}";
|
||||
contents = [ bashInteractive urbit startUrbit coreutils ];
|
||||
contents = [ bashInteractive urbit curl startUrbit getUrbitCode resetUrbitCode coreutils ];
|
||||
runAsRoot = ''
|
||||
#!${bashInteractive}
|
||||
mkdir -p /urbit
|
||||
|
@ -82,6 +82,8 @@ haskell-nix.stackProject {
|
||||
|
||||
urbit-king.components.tests.urbit-king-tests.testFlags =
|
||||
[ "--brass-pill=${brass.lfs}" ];
|
||||
|
||||
lmdb.components.library.libs = lib.mkForce [ lmdb ];
|
||||
};
|
||||
}];
|
||||
}
|
||||
|
@ -126,6 +126,14 @@
|
||||
!=(contact(last-updated *@da) u.old(last-updated *@da))
|
||||
==
|
||||
[~ state]
|
||||
~| "cannot add a data url to cover!"
|
||||
?> ?| ?=(~ cover.contact)
|
||||
!=('data:' (cut 3 [0 5] u.cover.contact))
|
||||
==
|
||||
~| "cannot add a data url to avatar!"
|
||||
?> ?| ?=(~ avatar.contact)
|
||||
!=('data:' (cut 3 [0 5] u.avatar.contact))
|
||||
==
|
||||
:- (send-diff [%add ship contact] =(ship our.bowl))
|
||||
state(rolodex (~(put by rolodex) ship contact))
|
||||
::
|
||||
@ -149,6 +157,14 @@
|
||||
=/ contact (edit-contact old edit-field)
|
||||
?: =(old contact)
|
||||
[~ state]
|
||||
~| "cannot add a data url to cover!"
|
||||
?> ?| ?=(~ cover.contact)
|
||||
!=('data:' (cut 3 [0 5] u.cover.contact))
|
||||
==
|
||||
~| "cannot add a data url to avatar!"
|
||||
?> ?| ?=(~ avatar.contact)
|
||||
!=('data:' (cut 3 [0 5] u.avatar.contact))
|
||||
==
|
||||
=. last-updated.contact timestamp
|
||||
:- (send-diff [%edit ship edit-field timestamp] =(ship our.bowl))
|
||||
state(rolodex (~(put by rolodex) ship contact))
|
||||
|
@ -243,7 +243,7 @@
|
||||
=/ headers
|
||||
:~ content-type+mime-type
|
||||
max-1-da:gen
|
||||
'Service-Worker-Allowed'^'/'
|
||||
'service-worker-allowed'^'/'
|
||||
==
|
||||
[[200 headers] `q.u.data]
|
||||
==
|
||||
|
@ -5,7 +5,7 @@
|
||||
/- glob
|
||||
/+ 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))]
|
||||
+$ all-states
|
||||
$% state-0
|
||||
|
@ -37,79 +37,13 @@
|
||||
++ on-init on-init:def
|
||||
++ on-save !>(~)
|
||||
++ on-load on-load:def
|
||||
++ on-poke
|
||||
|= [=mark =vase]
|
||||
^- (quip card _this)
|
||||
|^
|
||||
?. =(mark %sane)
|
||||
(on-poke:def mark vase)
|
||||
[(sane !<(?(%check %fix) vase)) this]
|
||||
::
|
||||
++ 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
|
||||
++ on-poke on-poke:def
|
||||
++ 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
|
||||
|= vas=vase
|
||||
|
@ -414,11 +414,8 @@
|
||||
?> ?& ?=(~ (~(dif in ships) members))
|
||||
(~(has by tags) tag)
|
||||
==
|
||||
%= +<
|
||||
::
|
||||
tags
|
||||
%+ ~(jab by tags) tag
|
||||
|=((set ship) (~(dif in +<) ships))
|
||||
%= +<
|
||||
tags (dif-ju tags tag ships)
|
||||
==
|
||||
:_ state
|
||||
(send-diff %remove-tag rid tag ships)
|
||||
@ -543,7 +540,15 @@
|
||||
(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
|
||||
|= [=tags ships=(set ship) new-tags=(set tag)]
|
||||
^+ tags
|
||||
|
@ -24,6 +24,6 @@
|
||||
<div id="portal-root"></div>
|
||||
<script src="/~landscape/js/channel.js"></script>
|
||||
<script src="/~landscape/js/session.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.857da0dbc92427b1460b.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.59e682153138f604d358.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -23,7 +23,7 @@
|
||||
:: /app-name/%app-name associations for app
|
||||
:: /group/%path associations for group
|
||||
::
|
||||
/- store=metadata-store
|
||||
/- store=metadata-store, pull-hook
|
||||
/+ default-agent, verb, dbug, resource, *migrate
|
||||
|%
|
||||
+$ card card:agent:gall
|
||||
@ -95,15 +95,17 @@
|
||||
~
|
||||
==
|
||||
::
|
||||
+$ state-0 [%0 base-state-0]
|
||||
+$ state-1 [%1 base-state-0]
|
||||
+$ state-2 [%2 base-state-0]
|
||||
+$ state-3 [%3 base-state-1]
|
||||
+$ state-4 [%4 base-state-1]
|
||||
+$ state-5 [%5 base-state-1]
|
||||
+$ state-6 [%6 base-state-1]
|
||||
+$ state-7 [%7 base-state-2]
|
||||
+$ state-8 [%8 base-state-3]
|
||||
+$ state-0 [%0 base-state-0]
|
||||
+$ state-1 [%1 base-state-0]
|
||||
+$ state-2 [%2 base-state-0]
|
||||
+$ state-3 [%3 base-state-1]
|
||||
+$ state-4 [%4 base-state-1]
|
||||
+$ state-5 [%5 base-state-1]
|
||||
+$ state-6 [%6 base-state-1]
|
||||
+$ state-7 [%7 base-state-2]
|
||||
+$ state-8 [%8 base-state-3]
|
||||
+$ state-9 [%9 base-state-3]
|
||||
+$ state-10 [%10 base-state-3]
|
||||
+$ versioned-state
|
||||
$% state-0
|
||||
state-1
|
||||
@ -114,10 +116,12 @@
|
||||
state-6
|
||||
state-7
|
||||
state-8
|
||||
state-9
|
||||
state-10
|
||||
==
|
||||
::
|
||||
+$ inflated-state
|
||||
$: state-8
|
||||
$: state-10
|
||||
cached-indices
|
||||
==
|
||||
--
|
||||
@ -230,7 +234,7 @@
|
||||
=| cards=(list card)
|
||||
|^
|
||||
=* loop $
|
||||
?: ?=(%8 -.old)
|
||||
?: ?=(%10 -.old)
|
||||
:- cards
|
||||
%_ state
|
||||
associations associations.old
|
||||
@ -238,6 +242,29 @@
|
||||
group-indices (rebuild-group-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)
|
||||
$(old [%8 (associations-2-to-3 associations.old) ~])
|
||||
?: ?=(%6 -.old)
|
||||
@ -369,7 +396,7 @@
|
||||
^- (quip card _state)
|
||||
|^
|
||||
=^ cards state
|
||||
(on-load !>([%8 (remake-metadata ;;(tree-metadata +.arc))]))
|
||||
(on-load !>([%9 (remake-metadata ;;(tree-metadata +.arc))]))
|
||||
:_ state
|
||||
%+ weld cards
|
||||
%+ turn ~(tap in ~(key by group-indices))
|
||||
|
@ -22,8 +22,9 @@
|
||||
++ notification-kind
|
||||
^- (unit notif-kind:hark)
|
||||
=/ len (lent index.p.i)
|
||||
?: =(1 len) ~
|
||||
`[%post [(dec len) len] %none %children]
|
||||
=/ =mode:hark
|
||||
?:(=(1 len) %count %none)
|
||||
`[%post [(dec len) len] mode %children]
|
||||
::
|
||||
++ transform-add-nodes
|
||||
|= [=index =post =atom was-parent-modified=?]
|
||||
|
@ -249,6 +249,7 @@
|
||||
font-family: "Source Code Pro";
|
||||
src: url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
:root {
|
||||
--red05: rgba(255,65,54,0.05);
|
||||
|
@ -7,9 +7,7 @@
|
||||
::
|
||||
++ supported-apps
|
||||
^- (list term)
|
||||
:~ %group-push-hook
|
||||
%group-store
|
||||
==
|
||||
~[%group-store]
|
||||
::
|
||||
++ poke-all-sane
|
||||
|= =input
|
||||
|
@ -25,9 +25,55 @@ The first two options result in Urbit attempting to boot either the ship named b
|
||||
In consequence, it is safe to remove the container and start a new container which mounts the same volume, e.g. to upgrade the version of the urbit binary by running a later container version. It is also possible to stop the container and then move the pier away e.g. to a location where you will run it directly with the Urbit binary.
|
||||
|
||||
### Ports
|
||||
The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port `80` is used for Urbit's HTTP interface for both [Landscape](https://urbit.org/docs/glossary/landscape/) and for [API calls](https://urbit.org/using/integrating-api/) to the ship. Port `34343` is used by [Ames](https://urbit.org/docs/glossary/ames/) for ship-to-ship communication.
|
||||
The image includes `EXPOSE` directives for TCP port 80 and UDP port 34343. Port `80` is used for Urbit's HTTP interface for both [Landscape](https://urbit.org/docs/glossary/landscape/) and for [API calls](https://urbit.org/using/integrating-api/) to the ship. Port `34343` is set by default to be used by [Ames](https://urbit.org/docs/glossary/ames/) for ship-to-ship communication.
|
||||
|
||||
You can either pass the `-P` flag to docker to map ports directly to the corresponding ports on the host, or map them individually with `-p` flags. For local testing the latter is often convenient, for instance to remap port 80 to an unprivileged port.
|
||||
You can either pass the `-P` flag to docker to map ports directly to the corresponding ports on the host, or map them individually with `-p` flags. For local testing the latter is often convenient, for instance to remap port 80 to an unprivileged port.
|
||||
|
||||
For best performance, you must map the Ames UDP port to the *same* port on the host. If you map to a different port Ames will not be able to make direct connections and your network performance may suffer somewhat. Note that using the same port is required for direct connections but is not by itself sufficient for them. If you are behind a NAT router or the host is not on a public IP address or you are firewalled, you may not achive direct connections regardless.
|
||||
|
||||
For this purpose you can force Ames to use a custom port. `/bin/start-urbit --port=$AMES_PORT` can be passed as an argument to the `docker start` command. Passing `/bin/start-urbit --port=13436` for example, would use port 13436. You must pass the name of the start script `/bin/start-urbit` in order to also pass arguments, if this is omitted your container will not start.
|
||||
|
||||
### Examples
|
||||
Creating a volume for ~sampel=palnet:
|
||||
```
|
||||
docker volume create sampel-palnet
|
||||
```
|
||||
|
||||
Copying key to sampel-palnet's volume (assumes default docker location):
|
||||
```
|
||||
sudo cp ~/sampel-palnet.key /var/lib/docker/volumes/sampel-palnet/_data/sampel-palnet.key
|
||||
```
|
||||
|
||||
Using that volume and launching ~sampel-palnet on host port 8080 with Ames talking on the default host port 34343:
|
||||
```
|
||||
docker run -d -p 8080:80 -p 34343:34343/udp --name sampel-palnet \
|
||||
--mount type=volume,source=sampel-palnet,destination=/urbit \
|
||||
tloncorp/urbit
|
||||
```
|
||||
|
||||
Using host port 8088 with Ames talking on host port 23232:
|
||||
```
|
||||
docker run -d -p 8088:80 -p 23232:23232/udp --name sampel-palnet \
|
||||
--mount type=volume,source=sampel-palnet,destination=/urbit \
|
||||
tloncorp/urbit /bin/start-urbit --port=23232
|
||||
```
|
||||
|
||||
### Getting and resetting the Landscape +code
|
||||
This docker image includes tools for retrieving and resetting the Landscape login code belonging to the planet, for programmatic use so the container does not need a tty. These scripts can be called using `docker container exec`.
|
||||
|
||||
Getting the code:
|
||||
```
|
||||
$ docker container exec sampel-palnet /bin/get-urbit-code
|
||||
sampel-sampel-sampel-sampel
|
||||
```
|
||||
|
||||
Resetting the code:
|
||||
```
|
||||
$ docker container exec sampel-palnet /bin/reset-urbit-code
|
||||
OK
|
||||
```
|
||||
|
||||
Once the code has been reset the new code can be obtained from `/bin/get-urbit-code`.
|
||||
|
||||
## Extending
|
||||
|
||||
|
@ -25,10 +25,10 @@ module Urbit.Arvo.Common
|
||||
import Urbit.Prelude
|
||||
|
||||
import Control.Monad.Fail (fail)
|
||||
import Data.Bits
|
||||
import Data.Serialize
|
||||
|
||||
import qualified Network.HTTP.Types.Method as H
|
||||
import qualified Network.Socket as N
|
||||
import qualified Urbit.Ob as Ob
|
||||
|
||||
|
||||
@ -159,6 +159,19 @@ deriveNoun ''JsonNode
|
||||
|
||||
-- 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 }
|
||||
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)
|
||||
|
||||
-- @if
|
||||
newtype Ipv4 = Ipv4 { unIpv4 :: Word32 }
|
||||
deriving newtype (Eq, Ord, Enum, Real, Integral, Num, ToNoun, FromNoun)
|
||||
newtype Ipv4 = Ipv4 { unIpv4 :: N.HostAddress }
|
||||
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
|
||||
show (Ipv4 i) =
|
||||
show ((shiftR i 24) .&. 0xff) ++ "." ++
|
||||
show ((shiftR i 16) .&. 0xff) ++ "." ++
|
||||
show ((shiftR i 8) .&. 0xff) ++ "." ++
|
||||
show (i .&. 0xff)
|
||||
show (Ipv4 (N.hostAddressToTuple -> (a, b, c, d))) =
|
||||
show a ++ "." ++
|
||||
show b ++ "." ++
|
||||
show c ++ "." ++
|
||||
show d
|
||||
|
||||
-- @is
|
||||
-- should probably use hostAddress6ToTuple here, but no one uses it right now
|
||||
newtype Ipv6 = Ipv6 { unIpv6 :: Word128 }
|
||||
deriving newtype (Eq, Ord, Show, Enum, Real, Integral, Num, ToNoun, FromNoun)
|
||||
|
||||
@ -190,21 +215,14 @@ data AmesAddress = AAIpv4 Ipv4 Port
|
||||
deriving (Eq, Ord, Show)
|
||||
|
||||
instance Serialize AmesAddress where
|
||||
get = AAIpv4 <$> (Ipv4 <$> getWord32le) <*> (Port <$> getWord16le)
|
||||
put (AAIpv4 (Ipv4 ip) (Port port)) = putWord32le ip >> putWord16le port
|
||||
get = AAIpv4 <$> get <*> (Port <$> getWord16le)
|
||||
put (AAIpv4 ip (Port port)) = put ip >> putWord16le port
|
||||
|
||||
instance FromNoun AmesAddress where
|
||||
parseNoun = named "AmesAddress" . \case
|
||||
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"
|
||||
parseNoun = serializeParseNoun "AmesAddress" 6
|
||||
|
||||
instance ToNoun AmesAddress where
|
||||
toNoun = A . bytesAtom . encode
|
||||
toNoun = serializeToNoun
|
||||
|
||||
type AmesDest = Each Galaxy AmesAddress
|
||||
|
||||
|
@ -80,10 +80,6 @@ data ShipClass
|
||||
muk :: ByteString -> Word20
|
||||
muk bs = mugBS bs .&. (2 ^ 20 - 1)
|
||||
|
||||
-- XX check this
|
||||
getAmesAddress :: Get AmesAddress
|
||||
getAmesAddress = AAIpv4 <$> (Ipv4 <$> getWord32le) <*> (Port <$> getWord16le)
|
||||
|
||||
putAmesAddress :: Putter AmesAddress
|
||||
putAmesAddress = \case
|
||||
AAIpv4 (Ipv4 ip) (Port port) -> putWord32le ip >> putWord16le port
|
||||
@ -104,7 +100,7 @@ instance Serialize Packet where
|
||||
guard isAmes
|
||||
|
||||
pktOrigin <- if isRelayed
|
||||
then Just <$> getAmesAddress
|
||||
then Just <$> get
|
||||
else pure Nothing
|
||||
|
||||
-- body
|
||||
@ -157,9 +153,10 @@ instance Serialize Packet where
|
||||
|
||||
putWord32le head
|
||||
case pktOrigin of
|
||||
Just o -> putAmesAddress o
|
||||
Just o -> put o
|
||||
Nothing -> pure ()
|
||||
putByteString body
|
||||
|
||||
where
|
||||
putShipGetRank s@(Ship (LargeKey p q)) = case () of
|
||||
_ | s < 2 ^ 16 -> (0, putWord16le $ fromIntegral s) -- lord
|
||||
|
@ -4,8 +4,12 @@
|
||||
1. Opens a UDP socket and makes sure that it stays open.
|
||||
|
||||
- If can't open the port, wait and try again repeatedly.
|
||||
- If there is an error reading or writting from the open socket,
|
||||
close it and open another.
|
||||
- If there is an error reading to or writing from the open socket,
|
||||
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.
|
||||
|
||||
@ -158,7 +162,7 @@ realUdpServ
|
||||
-> HostAddress
|
||||
-> AmesStat
|
||||
-> RIO e UdpServ
|
||||
realUdpServ por hos sat = do
|
||||
realUdpServ startPort hos sat = do
|
||||
logInfo $ displayShow ("AMES", "UDP", "Starting real UDP server.")
|
||||
|
||||
env <- ask
|
||||
@ -202,23 +206,30 @@ realUdpServ por hos sat = do
|
||||
did <- atomically (tryWriteTBQueue qSend (a, b))
|
||||
when (did == False) $ do
|
||||
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
|
||||
sk <- forceBind por hos
|
||||
sn <- io $ getSocketName sk
|
||||
let waitForRelease = do
|
||||
atomically (writeTVar vSock (Just sk))
|
||||
broken <- atomically (takeTMVar vFail)
|
||||
logWarn "AMES: UDP: Closing broken socket."
|
||||
io (close broken)
|
||||
|
||||
let waitForRelease = do
|
||||
atomically (writeTVar vSock (Just sk))
|
||||
broken <- atomically (takeTMVar vFail)
|
||||
logWarn "AMES: UDP: Closing broken socket."
|
||||
io (close broken)
|
||||
case sn of
|
||||
(SockAddrInet boundPort _) ->
|
||||
-- When we're on IPv4, maybe port forward at the NAT.
|
||||
rwith (requestPortAccess $ fromIntegral boundPort) $
|
||||
\() -> waitForRelease
|
||||
_ -> waitForRelease
|
||||
|
||||
case sn of
|
||||
(SockAddrInet boundPort _) ->
|
||||
-- When we're on IPv4, maybe port forward at the NAT.
|
||||
rwith (requestPortAccess $ fromIntegral boundPort) $
|
||||
\() -> waitForRelease
|
||||
_ -> waitForRelease
|
||||
opener sp
|
||||
|
||||
tOpen <- async $ opener startPort
|
||||
|
||||
tSend <- async $ forever $ join $ atomically $ do
|
||||
(adr, byt) <- readTBQueue qSend
|
||||
|
@ -37,12 +37,12 @@ textPlain = Path [(MkKnot "text"), (MkKnot "plain")]
|
||||
|
||||
-- | Filter for dotfiles, tempfiles and backup files.
|
||||
validClaySyncPath :: FilePath -> Bool
|
||||
validClaySyncPath fp = hasPeriod && notTildeFile && notDotHash && notDoubleHash
|
||||
validClaySyncPath fp = hasPeriod && notTildeFile && notDotFile && notDoubleHash
|
||||
where
|
||||
fileName = takeFileName fp
|
||||
hasPeriod = elem '.' fileName
|
||||
notTildeFile = not $ "~" `isSuffixOf` fileName
|
||||
notDotHash = not $ ".#" `isPrefixOf` fileName
|
||||
notDotFile = not $ "." `isPrefixOf` fileName
|
||||
notDoubleHash =
|
||||
not $ ("#" `isPrefixOf` fileName) && ("#" `isSuffixOf` fileName)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
name: urbit-king
|
||||
version: 1.3
|
||||
version: 1.5
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
data-files:
|
||||
|
@ -32,7 +32,7 @@ same (if [developing on a local development ship][local]). Then, from
|
||||
'pkg/interface':
|
||||
|
||||
```
|
||||
npm install
|
||||
npm ci
|
||||
npm run start
|
||||
```
|
||||
|
||||
@ -59,7 +59,7 @@ module.exports = {
|
||||
```
|
||||
|
||||
The dev environment will attempt to match the subdomain against the keys of this
|
||||
object, and if matched will proxy to the corresponding URL. For example, the
|
||||
object, and if matched will proxy to the corresponding URL. For example, the
|
||||
above config will proxy `zod.localhost:9000` to `http://localhost:8080`,
|
||||
`bus.localhost:9000` to `http://localhost:8081` and so on and so forth. If no
|
||||
match is found, then it will fallback to the `URL` property.
|
||||
@ -71,7 +71,7 @@ linter and for usage through the command, do the following:
|
||||
|
||||
```bash
|
||||
$ cd ./pkg/interface
|
||||
$ npm install
|
||||
$ npm ci
|
||||
$ npm run lint
|
||||
```
|
||||
|
||||
|
@ -98,8 +98,9 @@
|
||||
"lint-file": "eslint",
|
||||
"tsc": "tsc",
|
||||
"tsc:watch": "tsc --watch",
|
||||
"preinstall": "./preinstall.sh",
|
||||
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
|
||||
"build:prod": "cd ../npm/api && npm i && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
|
||||
"build:prod": "cd ../npm/api && npm ci && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
|
||||
"start": "webpack-dev-server --config config/webpack.dev.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
|
12
pkg/interface/preinstall.sh
Executable file
12
pkg/interface/preinstall.sh
Executable 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
|
@ -4,6 +4,11 @@ import { dateToDa, decToUd } from '../lib/util';
|
||||
import { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
|
||||
import { BigInteger } from 'big-integer';
|
||||
import { getParentIndex } from '../lib/notification';
|
||||
import useHarkState from '../state/hark';
|
||||
|
||||
function getHarkSize() {
|
||||
return useHarkState.getState().notifications.size ?? 0;
|
||||
}
|
||||
|
||||
export class HarkApi extends BaseApi<StoreState> {
|
||||
private harkAction(action: any): Promise<any> {
|
||||
@ -172,10 +177,10 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
}
|
||||
|
||||
async getMore(): Promise<boolean> {
|
||||
const offset = this.store.state['notifications']?.size || 0;
|
||||
const offset = getHarkSize();
|
||||
const count = 3;
|
||||
await this.getSubset(offset, count, false);
|
||||
return offset === (this.store.state.notifications?.size || 0);
|
||||
return offset === getHarkSize();
|
||||
}
|
||||
|
||||
async getSubset(offset:number, count:number, isArchive: boolean) {
|
||||
|
18
pkg/interface/src/logic/lib/formGroup.ts
Normal file
18
pkg/interface/src/logic/lib/formGroup.ts
Normal 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);
|
@ -1,6 +1,6 @@
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import f from 'lodash/fp';
|
||||
import { Unreads } from '@urbit/api';
|
||||
import { Unreads, NotificationGraphConfig } from '@urbit/api';
|
||||
|
||||
export function getLastSeen(
|
||||
unreads: Unreads,
|
||||
@ -31,6 +31,16 @@ export function getNotificationCount(
|
||||
): number {
|
||||
const unread = unreads.graph?.[path] || {};
|
||||
return Object.keys(unread)
|
||||
.map(index => unread[index]?.notifications || 0)
|
||||
.map(index => unread[index]?.notifications?.length || 0)
|
||||
.reduce(f.add, 0);
|
||||
}
|
||||
|
||||
export function isWatching(
|
||||
config: NotificationGraphConfig,
|
||||
graph: string,
|
||||
index = "/"
|
||||
) {
|
||||
return !!config.watching.find(
|
||||
watch => watch.graph === graph && watch.index === index
|
||||
);
|
||||
}
|
||||
|
@ -4,3 +4,7 @@ const ua = window.navigator.userAgent;
|
||||
export const IS_IOS = ua.includes('iPhone');
|
||||
|
||||
export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome');
|
||||
|
||||
export const IS_ANDROID = ua.includes('Android');
|
||||
|
||||
export const IS_MOBILE = IS_IOS || IS_ANDROID;
|
||||
|
@ -16,5 +16,5 @@ export function useCopy(copied: string, display: string) {
|
||||
display,
|
||||
]);
|
||||
|
||||
return { copyDisplay, doCopy };
|
||||
return { copyDisplay, doCopy, didCopy };
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ export function distanceToBottom(el: HTMLElement) {
|
||||
|
||||
export function useLazyScroll(
|
||||
ref: RefObject<HTMLElement>,
|
||||
ready: boolean,
|
||||
margin: number,
|
||||
count: number,
|
||||
loadMore: () => Promise<boolean>
|
||||
@ -41,7 +42,13 @@ export function useLazyScroll(
|
||||
}, [count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || isDone) {
|
||||
if(!ready) {
|
||||
setIsDone(false);
|
||||
}
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || isDone || !ready) {
|
||||
return;
|
||||
}
|
||||
const scroll = ref.current;
|
||||
@ -57,7 +64,7 @@ export function useLazyScroll(
|
||||
return () => {
|
||||
ref.current?.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [ref?.current, count]);
|
||||
}, [ref?.current, ready, isDone]);
|
||||
|
||||
return { isDone, isLoading };
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ export function useRunIO<I, O>(
|
||||
io: (i: I) => Promise<O>,
|
||||
after: (o: O) => void,
|
||||
key: string
|
||||
) {
|
||||
): () => Promise<void> {
|
||||
const [resolve, setResolve] = useState<() => void>(() => () => {});
|
||||
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
|
||||
const [output, setOutput] = useState<O | null>(null);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import _ from 'lodash';
|
||||
import f, { compose, memoize } from 'lodash/fp';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
@ -63,6 +63,16 @@ export function unixToDa(unix: number) {
|
||||
return DA_UNIX_EPOCH.add(timeSinceEpoch);
|
||||
}
|
||||
|
||||
export function dmCounterparty(resource: string) {
|
||||
const [,,ship,name] = resource.split('/');
|
||||
return ship === `~${window.ship}` ? `~${name.slice(4)}` : ship;
|
||||
}
|
||||
|
||||
export function isDm(resource: string) {
|
||||
const [,,,name] = resource.split('/');
|
||||
return name.startsWith('dm--');
|
||||
}
|
||||
|
||||
export function makePatDa(patda: string) {
|
||||
return bigInt(udToDec(patda));
|
||||
}
|
||||
@ -400,11 +410,15 @@ interface useHoveringInterface {
|
||||
|
||||
export const useHovering = (): useHoveringInterface => {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const bind = {
|
||||
onMouseOver: () => setHovering(true),
|
||||
onMouseLeave: () => setHovering(false)
|
||||
};
|
||||
return { hovering, bind };
|
||||
const onMouseOver = useCallback(() => setHovering(true), [])
|
||||
const onMouseLeave = useCallback(() => setHovering(false), [])
|
||||
const bind = useMemo(() => ({
|
||||
onMouseOver,
|
||||
onMouseLeave,
|
||||
}), [onMouseLeave, onMouseOver]);
|
||||
|
||||
|
||||
return useMemo(() => ({ hovering, bind }), [hovering, bind]);
|
||||
};
|
||||
|
||||
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;
|
||||
|
@ -8,12 +8,6 @@ export interface ContactState extends BaseState<ContactState> {
|
||||
isContactPublic: boolean;
|
||||
nackedContacts: Set<Patp>;
|
||||
// fetchIsAllowed: (entity, name, ship, personal) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useContact(ship: string) {
|
||||
return useContactState(
|
||||
useCallback(s => s.contacts[ship] as Contact | null, [ship])
|
||||
);
|
||||
}
|
||||
|
||||
const useContactState = createState<ContactState>('Contact', {
|
||||
@ -35,4 +29,10 @@ const useContactState = createState<ContactState>('Contact', {
|
||||
// },
|
||||
}, ['nackedContacts']);
|
||||
|
||||
export function useContact(ship: string) {
|
||||
return useContactState(
|
||||
useCallback(s => s.contacts[ship] as Contact | null, [ship])
|
||||
);
|
||||
}
|
||||
|
||||
export default useContactState;
|
||||
|
@ -16,7 +16,7 @@ const useGroupState = createState<GroupState>('Group', {
|
||||
}, ['groups']);
|
||||
|
||||
export function useGroup(group: string) {
|
||||
return useGroupState(useCallback(s => s.groups[group], [group]));
|
||||
return useGroupState(useCallback(s => s.groups[group] as Group | undefined, [group]));
|
||||
}
|
||||
|
||||
export function useGroupForAssoc(association: Association) {
|
||||
|
@ -15,7 +15,7 @@ export interface HarkState extends BaseState<HarkState> {
|
||||
notifications: BigIntOrderedMap<Timebox>;
|
||||
notificationsCount: number;
|
||||
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
|
||||
notificationsGroupConfig: []; // TODO type this
|
||||
notificationsGroupConfig: string[];
|
||||
unreads: Unreads;
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { MetadataUpdatePreview, Associations } from "@urbit/api";
|
||||
import { useCallback } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
|
||||
|
||||
import { BaseState, createState } from "./base";
|
||||
|
||||
@ -9,6 +11,19 @@ export interface MetadataState extends BaseState<MetadataState> {
|
||||
// preview: (group: string) => Promise<MetadataUpdatePreview>;
|
||||
};
|
||||
|
||||
export function useAssocForGraph(graph: string) {
|
||||
return useMetadataState(useCallback(s => s.associations.graph[graph] as Association | undefined, [graph]));
|
||||
}
|
||||
|
||||
export function useAssocForGroup(group: string) {
|
||||
return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group]));
|
||||
}
|
||||
|
||||
export function useGraphsForGroup(group: string) {
|
||||
const graphs = useMetadataState(s => s.associations.graph);
|
||||
return _.pickBy(graphs, (a: Association) => a.group === group);
|
||||
}
|
||||
|
||||
const useMetadataState = createState<MetadataState>('Metadata', {
|
||||
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
|
||||
// preview: async (group): Promise<MetadataUpdatePreview> => {
|
||||
@ -54,4 +69,4 @@ const useMetadataState = createState<MetadataState>('Metadata', {
|
||||
});
|
||||
|
||||
|
||||
export default useMetadataState;
|
||||
export default useMetadataState;
|
||||
|
@ -58,7 +58,7 @@ const useSettingsState = createState<SettingsState>('Settings', {
|
||||
categories: leapCategories,
|
||||
},
|
||||
tutorial: {
|
||||
seen: false,
|
||||
seen: true,
|
||||
joined: undefined
|
||||
}
|
||||
});
|
||||
|
@ -53,6 +53,7 @@ const Root = withState(styled.div`
|
||||
}
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
touch-action: none;
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
@ -1,4 +1,5 @@
|
||||
/* eslint-disable max-lines-per-function */
|
||||
import bigInt from 'big-integer';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@ -19,7 +20,8 @@ import {
|
||||
writeText,
|
||||
useShowNickname,
|
||||
useHideAvatar,
|
||||
useHovering
|
||||
useHovering,
|
||||
daToUnix
|
||||
} from '~/logic/lib/util';
|
||||
import {
|
||||
Group,
|
||||
@ -29,8 +31,8 @@ import {
|
||||
Groups,
|
||||
Associations
|
||||
} from '~/types';
|
||||
import TextContent from './content/text';
|
||||
import CodeContent from './content/code';
|
||||
import TextContent from '../../../landscape/components/Graph/content/text';
|
||||
import CodeContent from '../../../landscape/components/Graph/content/code';
|
||||
import RemoteContent from '~/views/components/RemoteContent';
|
||||
import { Mention } from '~/views/components/MentionText';
|
||||
import { Dropdown } from '~/views/components/Dropdown';
|
||||
@ -42,8 +44,7 @@ import useContactState from '~/logic/state/contact';
|
||||
import { useIdlingState } from '~/logic/lib/idling';
|
||||
import ProfileOverlay from '~/views/components/ProfileOverlay';
|
||||
import {useCopy} from '~/logic/lib/useCopy';
|
||||
import {PermalinkEmbed} from '../../permalinks/embed';
|
||||
import {referenceToPermalink} from '~/logic/lib/permalinks';
|
||||
import {GraphContentWide} from '~/views/landscape/components/Graph/GraphContentWide';
|
||||
|
||||
|
||||
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 =
|
||||
nextMsg &&
|
||||
new Date(msg['time-sent']).getDate() !==
|
||||
new Date(nextMsg['time-sent']).getDate();
|
||||
new Date(date).getDate() !==
|
||||
new Date(nextDate).getDate();
|
||||
|
||||
const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
|
||||
|
||||
const timestamp = moment
|
||||
.unix(msg['time-sent'] / 1000)
|
||||
.unix(date / 1000)
|
||||
.format(renderSigil ? 'h:mm A' : 'h:mm');
|
||||
|
||||
const messageProps = {
|
||||
@ -340,7 +346,7 @@ class ChatMessage extends Component<ChatMessageProps> {
|
||||
style={style}
|
||||
>
|
||||
{dayBreak && !isLastRead ? (
|
||||
<DayBreak when={msg['time-sent']} shimTop={renderSigil} />
|
||||
<DayBreak when={date} shimTop={renderSigil} />
|
||||
) : null}
|
||||
{renderSigil ? (
|
||||
<MessageWrapper {...messageProps}>
|
||||
@ -358,7 +364,7 @@ class ChatMessage extends Component<ChatMessageProps> {
|
||||
association={association}
|
||||
api={api}
|
||||
dayBreak={dayBreak}
|
||||
when={msg['time-sent']}
|
||||
when={date}
|
||||
ref={unreadMarkerRef}
|
||||
/>
|
||||
) : null}
|
||||
@ -388,20 +394,21 @@ export const MessageAuthor = ({
|
||||
const dark = theme === 'dark' || (theme === 'auto' && osDark);
|
||||
const contacts = useContactState((state) => state.contacts);
|
||||
|
||||
const date = daToUnix(bigInt(msg.index.split('/')[1]));
|
||||
|
||||
const datestamp = moment
|
||||
.unix(msg['time-sent'] / 1000)
|
||||
.unix(date / 1000)
|
||||
.format(DATESTAMP_FORMAT);
|
||||
const contact =
|
||||
((msg.author === window.ship && showOurContact) ||
|
||||
msg.author !== window.ship) &&
|
||||
`~${msg.author}` in contacts
|
||||
? contacts[`~${msg.author}`]
|
||||
: false;
|
||||
: undefined;
|
||||
|
||||
const showNickname = useShowNickname(contact);
|
||||
const { hideAvatars } = useSettingsState(selectCalmState);
|
||||
const shipName = showNickname ? contact.nickname : cite(msg.author);
|
||||
const copyNotice = 'Copied';
|
||||
const shipName = showNickname && contact?.nickname || cite(msg.author) || `~${msg.author}`;
|
||||
const color = contact
|
||||
? `#${uxToHex(contact.color)}`
|
||||
: dark
|
||||
@ -412,28 +419,10 @@ export const MessageAuthor = ({
|
||||
: dark
|
||||
? 'mix-blend-diff'
|
||||
: '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 [showOverlay, setShowOverlay] = useState(false);
|
||||
|
||||
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 nameMono = !(showNickname || didCopy);
|
||||
|
||||
const img =
|
||||
contact?.avatar && !hideAvatars ? (
|
||||
@ -470,10 +459,7 @@ export const MessageAuthor = ({
|
||||
return (
|
||||
<Box display='flex' alignItems='flex-start' {...rest}>
|
||||
<Box
|
||||
onClick={() => {
|
||||
setShowOverlay(true);
|
||||
}}
|
||||
height={24}
|
||||
height={24}
|
||||
pr={2}
|
||||
mt={'1px'}
|
||||
pl={'12px'}
|
||||
@ -500,13 +486,10 @@ export const MessageAuthor = ({
|
||||
mono={nameMono}
|
||||
fontWeight={nameMono ? '400' : '500'}
|
||||
cursor='pointer'
|
||||
onClick={() => {
|
||||
writeText(`~${msg.author}`);
|
||||
showCopyNotice();
|
||||
}}
|
||||
onClick={doCopy}
|
||||
title={`~${msg.author}`}
|
||||
>
|
||||
{displayName}
|
||||
{copyDisplay}
|
||||
</Text>
|
||||
<Text flexShrink={0} fontSize={0} gray>
|
||||
{timestamp}
|
||||
@ -538,7 +521,6 @@ export const Message = ({
|
||||
...rest
|
||||
}) => {
|
||||
const { hovering, bind } = useHovering();
|
||||
const contacts = useContactState((state) => state.contacts);
|
||||
return (
|
||||
<Box width="100%" position='relative' {...rest}>
|
||||
{timestampHover ? (
|
||||
@ -557,66 +539,14 @@ export const Message = ({
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<Box width="100%" {...bind}>
|
||||
{msg.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);
|
||||
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>
|
||||
<GraphContentWide
|
||||
{...bind}
|
||||
width="100%"
|
||||
post={msg}
|
||||
transcluded={transcluded}
|
||||
api={api}
|
||||
showOurContact={showOurContact}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -98,8 +98,15 @@ h2 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.embed-container:not(.embed-container .embed-container):not(.links) {
|
||||
padding: 0px 8px 8px 8px;
|
||||
}
|
||||
|
||||
.embed-container iframe {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mh-16 {
|
||||
|
@ -46,7 +46,7 @@ const ScrollbarLessBox = styled(Box)`
|
||||
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
|
||||
|
||||
export default function LaunchApp(props) {
|
||||
const connection = { props };
|
||||
const { connection } = props;
|
||||
const baseHash = useLaunchState(state => state.baseHash);
|
||||
const [hashText, setHashText] = useState(baseHash);
|
||||
const [exitingTut, setExitingTut] = useState(false);
|
||||
|
@ -1,65 +1,80 @@
|
||||
import React, { ReactNode, useCallback } from 'react';
|
||||
import moment from 'moment';
|
||||
import { Row, Box, Col, Text, Anchor, Icon, Action } from '@tlon/indigo-react';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import _ from 'lodash';
|
||||
import React, { ReactNode, useCallback } from "react";
|
||||
import moment from "moment";
|
||||
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
GraphNotifIndex,
|
||||
GraphNotificationContents,
|
||||
Associations,
|
||||
Rolodex,
|
||||
Groups
|
||||
} from '~/types';
|
||||
import { Header } from './header';
|
||||
import { cite, deSig, pluralize, useShowNickname } from '~/logic/lib/util';
|
||||
import Author from '~/views/components/Author';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { getSnippet } from '~/logic/lib/publish';
|
||||
import styled from 'styled-components';
|
||||
import { MentionText } from '~/views/components/MentionText';
|
||||
import ChatMessage from '../chat/components/ChatMessage';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import {PermalinkEmbed} from '../permalinks/embed';
|
||||
import {parsePermalink, referenceToPermalink} from '~/logic/lib/permalinks';
|
||||
Groups,
|
||||
} from "~/types";
|
||||
import { Header } from "./header";
|
||||
import {
|
||||
cite,
|
||||
deSig,
|
||||
pluralize,
|
||||
useShowNickname,
|
||||
isDm,
|
||||
} from "~/logic/lib/util";
|
||||
import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
|
||||
import Author from "~/views/components/Author";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import styled from "styled-components";
|
||||
import useContactState from "~/logic/state/contact";
|
||||
import useGroupState from "~/logic/state/group";
|
||||
import useMetadataState, {
|
||||
useAssocForGraph,
|
||||
useAssocForGroup,
|
||||
} from "~/logic/state/metadata";
|
||||
import { PermalinkEmbed } from "../permalinks/embed";
|
||||
import { parsePermalink, referenceToPermalink } from "~/logic/lib/permalinks";
|
||||
import { Post, Group, Association } from "@urbit/api";
|
||||
import { BigInteger } from "big-integer";
|
||||
|
||||
const TruncBox = styled(Box)<{ truncate?: number }>`
|
||||
-webkit-line-clamp: ${(p) => p.truncate ?? "unset"};
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
color: ${(p) => p.theme.colors.black};
|
||||
`;
|
||||
|
||||
function getGraphModuleIcon(module: string) {
|
||||
if (module === 'link') {
|
||||
return 'Collection';
|
||||
if (module === "link") {
|
||||
return "Collection";
|
||||
}
|
||||
if(module === 'post') {
|
||||
return 'Groups';
|
||||
if (module === "post") {
|
||||
return "Groups";
|
||||
}
|
||||
return _.capitalize(module);
|
||||
}
|
||||
|
||||
const FilterBox = styled(Box)`
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
${(p) => p.theme.colors.white}
|
||||
);
|
||||
`;
|
||||
|
||||
function describeNotification(description: string, plural: boolean): string {
|
||||
function describeNotification(
|
||||
description: string,
|
||||
plural: boolean,
|
||||
isDm: boolean,
|
||||
singleAuthor: boolean
|
||||
): string {
|
||||
switch (description) {
|
||||
case 'post':
|
||||
return 'replied to you';
|
||||
case 'link':
|
||||
return `added ${pluralize('new link', plural)} to`;
|
||||
case 'comment':
|
||||
return `left ${pluralize('comment', plural)} on`;
|
||||
case 'edit-comment':
|
||||
return `updated ${pluralize('comment', plural)} on`;
|
||||
case 'note':
|
||||
return `posted ${pluralize('note', plural)} to`;
|
||||
case 'edit-note':
|
||||
return `updated ${pluralize('note', plural)} in`;
|
||||
case 'mention':
|
||||
return 'mentioned you on';
|
||||
case 'message':
|
||||
return `sent ${pluralize('message', plural)} to`;
|
||||
case "post":
|
||||
return singleAuthor ? "replied to you" : "Your post received replies";
|
||||
case "link":
|
||||
return `New link${plural ? "s" : ""} in`;
|
||||
case "comment":
|
||||
return `New comment${plural ? "s" : ""} on`;
|
||||
case "note":
|
||||
return `New Note${plural ? "s" : ""} in`;
|
||||
case "edit-note":
|
||||
return `updated ${pluralize("note", plural)} in`;
|
||||
case "mention":
|
||||
return singleAuthor ? "mentioned you in" : "You were mentioned in";
|
||||
case "message":
|
||||
if (isDm) {
|
||||
return "messaged you";
|
||||
}
|
||||
return `New message${plural ? "s" : ""} in`;
|
||||
default:
|
||||
return description;
|
||||
}
|
||||
@ -67,105 +82,87 @@ function describeNotification(description: string, plural: boolean): string {
|
||||
|
||||
const GraphUrl = ({ contents, api }) => {
|
||||
const [{ text }, link] = contents;
|
||||
|
||||
|
||||
if('reference' in link) {
|
||||
if ("reference" in link) {
|
||||
return (
|
||||
<PermalinkEmbed
|
||||
<PermalinkEmbed
|
||||
transcluded={1}
|
||||
link={referenceToPermalink(link).link}
|
||||
api={api}
|
||||
showOurContact
|
||||
/>);
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box borderRadius='2' p='2' bg='scales.black05'>
|
||||
<Anchor underline={false} target='_blank' color='black' href={link.url}>
|
||||
<Icon verticalAlign='bottom' mr='2' icon='ArrowExternal' />
|
||||
<Box borderRadius="2" p="2" bg="scales.black05">
|
||||
<Anchor underline={false} target="_blank" color="black" href={link.url}>
|
||||
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
|
||||
{text}
|
||||
</Anchor>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function ContentSummary({ icon, name, author, to }) {
|
||||
return (
|
||||
<Link to={to}>
|
||||
<Col
|
||||
gapY="1"
|
||||
flexDirection={["column", "row"]}
|
||||
alignItems={["flex-start", "center"]}
|
||||
>
|
||||
<Row
|
||||
alignItems="center"
|
||||
gapX="2"
|
||||
p="1"
|
||||
width="fit-content"
|
||||
borderRadius="2"
|
||||
border="1"
|
||||
borderColor="lightGray"
|
||||
>
|
||||
<Icon display="block" icon={icon} />
|
||||
<Text verticalAlign="baseline" fontWeight="medium">
|
||||
{name}
|
||||
</Text>
|
||||
</Row>
|
||||
<Row ml={[0, 1]} alignItems="center">
|
||||
<Text lineHeight="1" fontWeight="medium" mr="1">
|
||||
by
|
||||
</Text>
|
||||
<Author
|
||||
sigilPadding={6}
|
||||
size={24}
|
||||
dontShowTime
|
||||
ship={author}
|
||||
showImage
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const GraphNodeContent = ({
|
||||
group,
|
||||
association,
|
||||
post,
|
||||
mod,
|
||||
index,
|
||||
}) => {
|
||||
export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
|
||||
const { contents } = post;
|
||||
const idx = index.slice(1).split('/');
|
||||
if (mod === 'link') {
|
||||
if (idx.length === 1) {
|
||||
return <GraphUrl contents={contents} />;
|
||||
} else if (idx.length === 3) {
|
||||
return <MentionText content={contents} group={group} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (mod === 'publish') {
|
||||
if (idx[1] === '2') {
|
||||
return (
|
||||
<MentionText
|
||||
content={contents}
|
||||
group={group}
|
||||
fontSize='14px'
|
||||
lineHeight='tall'
|
||||
/>
|
||||
);
|
||||
} else if (idx[1] === '1') {
|
||||
const [{ text: header }, { text: body }] = contents;
|
||||
const snippet = getSnippet(body);
|
||||
return (
|
||||
<Col>
|
||||
<Box mb='2' fontWeight='500'>
|
||||
<Text>{header}</Text>
|
||||
</Box>
|
||||
<Box overflow='hidden' maxHeight='400px' position='relative'>
|
||||
<Text lineHeight='tall'>{snippet}</Text>
|
||||
<FilterBox
|
||||
width='100%'
|
||||
zIndex='1'
|
||||
height='calc(100% - 2em)'
|
||||
bottom='-4px'
|
||||
position='absolute'
|
||||
/>
|
||||
</Box>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
}
|
||||
if(mod === 'post') {
|
||||
return <MentionText content={contents} group={group} />;
|
||||
}
|
||||
|
||||
if (mod === 'chat') {
|
||||
const idx = index.slice(1).split("/");
|
||||
const url = getNodeUrl(mod, hidden, association?.group, association?.resource, index);
|
||||
if (mod === "link" && idx.length === 1) {
|
||||
const [{ text: title }] = contents;
|
||||
return (
|
||||
<Row
|
||||
width='100%'
|
||||
flexShrink={0}
|
||||
flexGrow={1}
|
||||
flexWrap='wrap'
|
||||
marginLeft='-32px'
|
||||
>
|
||||
<ChatMessage
|
||||
renderSigil={false}
|
||||
containerClass='items-top cf hide-child'
|
||||
group={group}
|
||||
groups={{}}
|
||||
association={association}
|
||||
associations={{ graph: {}, groups: {} }}
|
||||
msg={post}
|
||||
fontSize='0'
|
||||
pt='2'
|
||||
hideHover={true}
|
||||
/>
|
||||
</Row>
|
||||
<ContentSummary to={url} icon="Links" name={title} author={post.author} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
if (mod === "publish" && idx[1] === "1") {
|
||||
const [{ text: title }] = contents;
|
||||
return (
|
||||
<ContentSummary to={url} icon="Note" name={title} author={post.author} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TruncBox truncate={8}>
|
||||
<GraphContentWide api={{} as any} post={post} showOurContact />
|
||||
</TruncBox>
|
||||
);
|
||||
};
|
||||
|
||||
function getNodeUrl(
|
||||
@ -175,78 +172,103 @@ function getNodeUrl(
|
||||
graph: string,
|
||||
index: string
|
||||
) {
|
||||
if (hidden && mod === 'chat') {
|
||||
groupPath = '/messages';
|
||||
if (hidden && mod === "chat") {
|
||||
groupPath = "/messages";
|
||||
} else if (hidden) {
|
||||
groupPath = '/home';
|
||||
groupPath = "/home";
|
||||
}
|
||||
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
|
||||
const idx = index.slice(1).split('/');
|
||||
if (mod === 'publish') {
|
||||
const [noteId] = idx;
|
||||
return `${graphUrl}/note/${noteId}`;
|
||||
} else if (mod === 'link') {
|
||||
const [linkId] = idx;
|
||||
return `${graphUrl}/index/${linkId}`;
|
||||
} else if (mod === 'chat') {
|
||||
if(idx.length > 0) {
|
||||
const idx = index.slice(1).split("/");
|
||||
if (mod === "publish") {
|
||||
console.log(idx);
|
||||
const [noteId, kind, commId] = idx;
|
||||
const selected = kind === "2" ? `?selected=${commId}` : "";
|
||||
return `${graphUrl}/note/${noteId}${selected}`;
|
||||
} else if (mod === "link") {
|
||||
const [linkId, commId] = idx;
|
||||
return `${graphUrl}/index/${linkId}${commId ? `?selected=${commId}` : ""}`;
|
||||
} else if (mod === "chat") {
|
||||
if (idx.length > 0) {
|
||||
return `${graphUrl}?msg=${idx[0]}`;
|
||||
}
|
||||
return graphUrl;
|
||||
} else if( mod === 'post') {
|
||||
} else if (mod === "post") {
|
||||
return `/~landscape${groupPath}/feed${index}`;
|
||||
}
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
const GraphNode = ({
|
||||
post,
|
||||
author,
|
||||
mod,
|
||||
description,
|
||||
time,
|
||||
index,
|
||||
graph,
|
||||
groupPath,
|
||||
group,
|
||||
read,
|
||||
onRead,
|
||||
showContact = false
|
||||
}) => {
|
||||
author = deSig(author);
|
||||
const history = useHistory();
|
||||
const contacts = useContactState((state) => state.contacts);
|
||||
|
||||
const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index);
|
||||
const association = useMetadataState(
|
||||
useCallback(s => s.associations.graph[graph], [graph])
|
||||
interface PostsByAuthor {
|
||||
author: string;
|
||||
posts: Post[];
|
||||
}
|
||||
const GraphNodes = (props: {
|
||||
posts: Post[];
|
||||
graph: string;
|
||||
hideAuthors?: boolean;
|
||||
group?: Group;
|
||||
groupPath: string;
|
||||
description: string;
|
||||
index: string;
|
||||
mod: string;
|
||||
association: Association;
|
||||
hidden: boolean;
|
||||
}) => {
|
||||
const {
|
||||
posts,
|
||||
mod,
|
||||
hidden,
|
||||
index,
|
||||
description,
|
||||
hideAuthors = false,
|
||||
association,
|
||||
} = props;
|
||||
|
||||
const postsByConsecAuthor = _.reduce(
|
||||
posts,
|
||||
(acc: PostsByAuthor[], val: Post, key: number) => {
|
||||
const lent = acc.length;
|
||||
if (lent > 0 && acc?.[lent - 1]?.author === val.author) {
|
||||
const last = acc[lent - 1];
|
||||
const rest = acc.slice(0, -1);
|
||||
return [...rest, { ...last, posts: [...last.posts, val] }];
|
||||
}
|
||||
return [...acc, { author: val.author, posts: [val] }];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (!read) {
|
||||
onRead();
|
||||
}
|
||||
history.push(nodeUrl);
|
||||
}, [read, onRead]);
|
||||
|
||||
return (
|
||||
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}>
|
||||
<Col flexGrow={1} alignItems='flex-start'>
|
||||
{showContact && (
|
||||
<Author showImage ship={author} date={time} group={group} />
|
||||
)}
|
||||
<Row width='100%' p='1' flexDirection='column'>
|
||||
<GraphNodeContent
|
||||
post={post}
|
||||
mod={mod}
|
||||
description={description}
|
||||
association={association}
|
||||
index={index}
|
||||
group={group}
|
||||
remoteContentPolicy={{}}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<>
|
||||
{_.map(postsByConsecAuthor, ({ posts, author }, idx) => {
|
||||
const time = posts[0]?.["time-sent"];
|
||||
return (
|
||||
<Col key={idx} flexGrow={1} alignItems="flex-start">
|
||||
{!hideAuthors && (
|
||||
<Author
|
||||
size={24}
|
||||
sigilPadding={6}
|
||||
showImage
|
||||
ship={author}
|
||||
date={time}
|
||||
/>
|
||||
)}
|
||||
<Col gapY="2" py={hideAuthors ? 0 : 2} width="100%">
|
||||
{_.map(posts, (post) => (
|
||||
<GraphNodeContent
|
||||
key={post.index}
|
||||
post={post}
|
||||
mod={mod}
|
||||
index={index}
|
||||
association={association}
|
||||
hidden={hidden}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -260,53 +282,79 @@ export function GraphNotification(props: {
|
||||
api: GlobalApi;
|
||||
}) {
|
||||
const { contents, index, read, time, api, timebox } = props;
|
||||
const history = useHistory();
|
||||
|
||||
const authors = _.map(contents, 'author');
|
||||
const authors = _.uniq(_.map(contents, "author"));
|
||||
const singleAuthor = authors.length === 1;
|
||||
const { graph, group } = index;
|
||||
const icon = getGraphModuleIcon(index.module);
|
||||
const desc = describeNotification(index.description, contents.length !== 1);
|
||||
const association = useAssocForGraph(graph)!;
|
||||
const dm = isDm(graph);
|
||||
const desc = describeNotification(
|
||||
index.description,
|
||||
contents.length !== 1,
|
||||
dm,
|
||||
singleAuthor
|
||||
);
|
||||
const groupAssociation = useAssocForGroup(association?.group);
|
||||
const groups = useGroupState((state) => state.groups);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (props.archived || read) {
|
||||
return;
|
||||
if (
|
||||
!(
|
||||
(index.description === "note" || index.description === "link") &&
|
||||
index.index === "/"
|
||||
)
|
||||
) {
|
||||
const first = contents[0];
|
||||
history.push(
|
||||
getNodeUrl(
|
||||
index.module,
|
||||
groups[association?.group]?.hidden,
|
||||
group,
|
||||
association?.resource,
|
||||
first.index
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return api.hark['read'](timebox, { graph: index });
|
||||
}, [api, timebox, index, read]);
|
||||
|
||||
const groups = useGroupState((state) => state.groups);
|
||||
const authorsInHeader =
|
||||
dm ||
|
||||
((index.description === "mention" || index.description === "post") &&
|
||||
singleAuthor);
|
||||
const hideAuthors =
|
||||
authorsInHeader ||
|
||||
index.description === "note" ||
|
||||
index.description === "link";
|
||||
const channelTitle = dm ? undefined : association?.metadata?.title ?? graph;
|
||||
const groupTitle = groupAssociation?.metadata?.title;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
onClick={onClick}
|
||||
archived={props.archived}
|
||||
time={time}
|
||||
read={read}
|
||||
authors={authors}
|
||||
moduleIcon={icon}
|
||||
channel={graph}
|
||||
group={group}
|
||||
authors={authorsInHeader ? authors : []}
|
||||
channelTitle={channelTitle}
|
||||
description={desc}
|
||||
groupTitle={groupTitle}
|
||||
content
|
||||
/>
|
||||
<Box flexGrow={1} width='100%' pl={5} gridArea='main'>
|
||||
{_.map(contents, (content, idx) => (
|
||||
<GraphNode
|
||||
post={content}
|
||||
author={content.author}
|
||||
mod={index.module}
|
||||
time={content?.['time-sent']}
|
||||
description={index.description}
|
||||
index={content.index}
|
||||
graph={graph}
|
||||
group={groups[group]}
|
||||
groupPath={group}
|
||||
read={read}
|
||||
onRead={onClick}
|
||||
showContact={idx === 0}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Col onClick={onClick} gapY="2" flexGrow={1} width="100%" gridArea="main">
|
||||
<GraphNodes
|
||||
hideAuthors={hideAuthors}
|
||||
posts={contents.slice(0, 4)}
|
||||
mod={index.module}
|
||||
description={index.description}
|
||||
index={contents?.[0].index}
|
||||
association={association}
|
||||
hidden={groups[association?.group]?.hidden}
|
||||
/>
|
||||
{contents.length > 4 && (
|
||||
<Text mb="2" gray>
|
||||
+ {contents.length - 4} more
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { Header } from './header';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import {useAssocForGroup} from '~/logic/state/metadata';
|
||||
|
||||
function describeNotification(description: string, plural: boolean) {
|
||||
switch (description) {
|
||||
@ -52,23 +53,16 @@ export function GroupNotification(props: GroupNotificationProps): ReactElement {
|
||||
const { group } = index;
|
||||
const desc = describeNotification(index.description, contents.length !== 1);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (props.archived) {
|
||||
return;
|
||||
}
|
||||
const func = read ? 'unread' : 'read';
|
||||
return api.hark[func](timebox, { group: index });
|
||||
}, [api, timebox, index, read]);
|
||||
const association = useAssocForGroup(group)
|
||||
const groupTitle = association?.metadata?.title ?? group;
|
||||
|
||||
return (
|
||||
<Col onClick={onClick} p="2">
|
||||
<Col>
|
||||
<Header
|
||||
archived={props.archived}
|
||||
time={time}
|
||||
read={read}
|
||||
group={group}
|
||||
authors={authors}
|
||||
description={desc}
|
||||
groupTitle={groupTitle}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
|
@ -1,103 +1,90 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import f from 'lodash/fp';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React, { ReactElement } from "react";
|
||||
import _ from "lodash";
|
||||
import moment from "moment";
|
||||
import { Text as NormalText, Row, Rule, Box, Col } from "@tlon/indigo-react";
|
||||
|
||||
import { Text as NormalText, Row, Icon, Rule } from '@tlon/indigo-react';
|
||||
import { Associations, Contact, Contacts, Rolodex } from '@urbit/api';
|
||||
|
||||
import { PropFunc } from '~/types/util';
|
||||
import { useShowNickname } from '~/logic/lib/util';
|
||||
import Timestamp from '~/views/components/Timestamp';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import { PropFunc } from "~/types/util";
|
||||
import Timestamp from "~/views/components/Timestamp";
|
||||
import Author from "~/views/components/Author";
|
||||
import Dot from "~/views/components/Dot";
|
||||
|
||||
const Text = (props: PropFunc<typeof Text>) => (
|
||||
<NormalText fontWeight="500" {...props} />
|
||||
);
|
||||
|
||||
function Author(props: { patp: string; last?: boolean }): ReactElement {
|
||||
const contacts = useContactState(state => state.contacts);
|
||||
const contact: Contact | undefined = contacts?.[`~${props.patp}`];
|
||||
|
||||
const showNickname = useShowNickname(contact);
|
||||
const name = showNickname ? contact.nickname : `~${props.patp}`;
|
||||
export function Header(
|
||||
props: {
|
||||
channelTitle?: string;
|
||||
groupTitle?: string;
|
||||
description: string;
|
||||
time?: number;
|
||||
authors?: string[];
|
||||
content?: boolean;
|
||||
} & PropFunc<typeof Row>
|
||||
): ReactElement {
|
||||
const {
|
||||
description,
|
||||
channelTitle = "",
|
||||
groupTitle,
|
||||
authors = [],
|
||||
content = false,
|
||||
time,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Text mono={!showNickname}>
|
||||
{name}
|
||||
{!props.last && ', '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header(props: {
|
||||
authors: string[];
|
||||
archived?: boolean;
|
||||
channel?: string;
|
||||
group: string;
|
||||
description: string;
|
||||
moduleIcon?: string;
|
||||
time: number;
|
||||
read: boolean;
|
||||
} & PropFunc<typeof Row> ): ReactElement {
|
||||
const { description, channel, moduleIcon, read } = props;
|
||||
const associations = useMetadataState(state => state.associations);
|
||||
|
||||
const authors = _.uniq(props.authors);
|
||||
|
||||
const authorDesc = f.flow(
|
||||
f.take(3),
|
||||
f.entries,
|
||||
f.map(([idx, p]: [string, string]) => {
|
||||
const lent = Math.min(3, authors.length);
|
||||
const last = lent - 1 === parseInt(idx, 10);
|
||||
return <Author key={idx} patp={p} last={last} />;
|
||||
}),
|
||||
auths => (
|
||||
<React.Fragment>
|
||||
{auths}
|
||||
|
||||
{authors.length > 3 &&
|
||||
` and ${authors.length - 3} other${authors.length === 4 ? '' : 's'}`}
|
||||
</React.Fragment>
|
||||
)
|
||||
)(authors);
|
||||
|
||||
const time = moment(props.time).format('HH:mm');
|
||||
const groupTitle =
|
||||
associations.groups?.[props.group]?.metadata?.title;
|
||||
|
||||
const app = 'graph';
|
||||
const channelTitle =
|
||||
(channel && associations?.[app]?.[channel]?.metadata?.title) ||
|
||||
channel;
|
||||
|
||||
return (
|
||||
<Row onClick={props.onClick} p="2" flexWrap="wrap" alignItems="center" gridArea="header">
|
||||
{!props.archived && (
|
||||
<Icon
|
||||
display="block"
|
||||
opacity={read ? 0 : 1}
|
||||
mr={2}
|
||||
icon="Bullet"
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
<Text mr="1" mono>
|
||||
{authorDesc}
|
||||
</Text>
|
||||
<Text mr="1">{description}</Text>
|
||||
{Boolean(moduleIcon) && <Icon icon={moduleIcon as any} mr={1} />}
|
||||
{Boolean(channel) && <Text fontWeight="500" mr={1}>{channelTitle}</Text>}
|
||||
<Rule vertical height="12px" mr={1} />
|
||||
{groupTitle &&
|
||||
<>
|
||||
<Text fontWeight="500" mr={1}>{groupTitle}</Text>
|
||||
<Rule vertical height="12px" mr={1} />
|
||||
</>
|
||||
}
|
||||
<Timestamp stamp={moment(props.time)} color="lightGray" date={false} />
|
||||
<Row
|
||||
flexDirection={["column-reverse", "row"]}
|
||||
minHeight="4"
|
||||
mb={content ? 2 : 0}
|
||||
onClick={props.onClick}
|
||||
flexWrap="wrap"
|
||||
alignItems={["flex-start", "center"]}
|
||||
gridArea="header"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Row gapX="1" overflow="hidden" alignItems="center">
|
||||
{authors.length > 0 && (
|
||||
<>
|
||||
<Author
|
||||
flexShrink={0}
|
||||
sigilPadding={6}
|
||||
size={24}
|
||||
dontShowTime
|
||||
date={time}
|
||||
ship={authors[0]}
|
||||
showImage
|
||||
/>
|
||||
{authors.length > 1 && (
|
||||
<Text lineHeight="tall">+ {authors.length - 1} more</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Box whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
|
||||
<Text lineHeight="tall" mr="1">
|
||||
{description} {channelTitle}
|
||||
</Text>
|
||||
</Box>
|
||||
</Row>
|
||||
<Row ml={[0, 1]} mb={[1, 0]} gapX="1" alignItems="center">
|
||||
{groupTitle && (
|
||||
<>
|
||||
<Text lineHeight="tall" fontSize="1" gray>
|
||||
{groupTitle}
|
||||
</Text>
|
||||
<Dot color="gray" />
|
||||
</>
|
||||
)}
|
||||
{time && (
|
||||
<Timestamp
|
||||
lineHeight="tall"
|
||||
fontSize="1"
|
||||
relative
|
||||
stamp={moment(time)}
|
||||
color="gray"
|
||||
date={false}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import { Invites } from './invites';
|
||||
import { useLazyScroll } from '~/logic/lib/useLazyScroll';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
import useInviteState from '~/logic/state/invite';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
|
||||
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 archivedNotifications = useHarkState(state => state.archivedNotifications);
|
||||
|
||||
@ -109,13 +114,14 @@ export default function Inbox(props: {
|
||||
|
||||
const { isDone, isLoading } = useLazyScroll(
|
||||
scrollRef,
|
||||
ready,
|
||||
0.2,
|
||||
_.flatten(notifications).length,
|
||||
loadMore
|
||||
);
|
||||
|
||||
return (
|
||||
<Col ref={scrollRef} position="relative" height="100%" overflowY="auto">
|
||||
<Col p="1" ref={scrollRef} position="relative" height="100%" overflowY="auto">
|
||||
<Invites pendingJoin={props.pendingJoin} api={api} />
|
||||
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
|
||||
const timeboxes = notificationsByDayMap.get(day)!;
|
||||
@ -169,26 +175,15 @@ function DaySection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box position="sticky" zIndex={3} top="-1px" bg="white">
|
||||
<Box p="2" bg="scales.black05">
|
||||
<Text>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
|
||||
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
|
||||
<React.Fragment key={j}>
|
||||
{(i !== 0 || j !== 0) && (
|
||||
<Box flexShrink={0} height="4px" bg="scales.black05" />
|
||||
)}
|
||||
<Notification
|
||||
api={api}
|
||||
notification={not}
|
||||
archived={archive}
|
||||
time={date}
|
||||
/>
|
||||
</React.Fragment>
|
||||
<Notification
|
||||
key={j}
|
||||
api={api}
|
||||
notification={not}
|
||||
archived={archive}
|
||||
time={date}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
|
@ -59,13 +59,6 @@ export function Invites(props: InvitesProps): ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(invitesAndStatus).length > 0 && (
|
||||
<Box position="sticky" zIndex={3} top="-1px" bg="white" flexShrink="0">
|
||||
<Box p="2" bg="scales.black05">
|
||||
<Text>Invites</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{Object.keys(invitesAndStatus)
|
||||
.sort(alphabeticalOrder)
|
||||
.map((resource) => {
|
||||
@ -89,10 +82,9 @@ export function Invites(props: InvitesProps): ReactElement {
|
||||
invite={invite}
|
||||
app={app}
|
||||
uid={uid}
|
||||
join={join}
|
||||
resource={resource}
|
||||
/>
|
||||
);
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { Row, Box } from '@tlon/indigo-react';
|
||||
import _ from 'lodash';
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { Row, Box, Icon } from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
GraphNotificationContents,
|
||||
IndexedNotification,
|
||||
@ -9,16 +9,17 @@ import {
|
||||
GroupNotificationsConfig,
|
||||
Groups,
|
||||
Associations,
|
||||
Contacts
|
||||
} from '@urbit/api';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { getParentIndex } from '~/logic/lib/notification';
|
||||
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
|
||||
import { GroupNotification } from './group';
|
||||
import { GraphNotification } from './graph';
|
||||
import { BigInteger } from 'big-integer';
|
||||
import { useHovering } from '~/logic/lib/util';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
Contacts,
|
||||
} from "@urbit/api";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { getParentIndex } from "~/logic/lib/notification";
|
||||
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
|
||||
import { GroupNotification } from "./group";
|
||||
import { GraphNotification } from "./graph";
|
||||
import { BigInteger } from "big-integer";
|
||||
import { useHovering } from "~/logic/lib/util";
|
||||
import useHarkState from "~/logic/state/hark";
|
||||
import {IS_MOBILE} from "~/logic/lib/platform";
|
||||
|
||||
interface NotificationProps {
|
||||
notification: IndexedNotification;
|
||||
@ -33,72 +34,98 @@ function getMuted(
|
||||
graphs: NotificationGraphConfig
|
||||
) {
|
||||
const { index, notification } = idxNotif;
|
||||
if ('graph' in idxNotif.index) {
|
||||
if ("graph" in idxNotif.index) {
|
||||
const { graph } = idxNotif.index.graph;
|
||||
if(!('graph' in notification.contents)) {
|
||||
if (!("graph" in notification.contents)) {
|
||||
throw new Error();
|
||||
}
|
||||
const parent = getParentIndex(index.graph, notification.contents.graph);
|
||||
|
||||
return _.findIndex(
|
||||
graphs?.watching || [],
|
||||
g => g.graph === graph && g.index === parent
|
||||
) === -1;
|
||||
return (
|
||||
_.findIndex(
|
||||
graphs?.watching || [],
|
||||
(g) => g.graph === graph && g.index === parent
|
||||
) === -1
|
||||
);
|
||||
}
|
||||
if ('group' in index) {
|
||||
return _.findIndex(groups || [], g => g === index.group.group) === -1;
|
||||
if ("group" in index) {
|
||||
return _.findIndex(groups || [], (g) => g === index.group.group) === -1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function NotificationWrapper(props: {
|
||||
export function NotificationWrapper(props: {
|
||||
api: GlobalApi;
|
||||
time: BigInteger;
|
||||
notif: IndexedNotification;
|
||||
time?: BigInteger;
|
||||
notification?: IndexedNotification;
|
||||
children: ReactNode;
|
||||
archived: boolean;
|
||||
}) {
|
||||
const { api, time, notif, children } = props;
|
||||
const { api, time, notification, children } = props;
|
||||
|
||||
const onArchive = useCallback(async () => {
|
||||
return api.hark.archive(time, notif.index);
|
||||
}, [time, notif]);
|
||||
if (!(time && notification)) {
|
||||
return;
|
||||
}
|
||||
return api.hark.archive(time, notification.index);
|
||||
}, [time, notification]);
|
||||
|
||||
const groupConfig = useHarkState(state => state.notificationsGroupConfig);
|
||||
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
|
||||
const groupConfig = useHarkState((state) => state.notificationsGroupConfig);
|
||||
const graphConfig = useHarkState((state) => state.notificationsGraphConfig);
|
||||
|
||||
const isMuted = getMuted(
|
||||
notif,
|
||||
groupConfig,
|
||||
graphConfig
|
||||
);
|
||||
const isMuted =
|
||||
time && notification && getMuted(notification, groupConfig, graphConfig);
|
||||
|
||||
const onChangeMute = useCallback(async () => {
|
||||
const func = isMuted ? 'unmute' : 'mute';
|
||||
return api.hark[func](notif);
|
||||
}, [notif, api, isMuted]);
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
const func = isMuted ? "unmute" : "mute";
|
||||
return api.hark[func](notification);
|
||||
}, [notification, api, isMuted]);
|
||||
|
||||
const onClick = () => {
|
||||
if (!(time && notification) || notification.notification.read) {
|
||||
return;
|
||||
}
|
||||
return api.hark.read(time, notification.index);
|
||||
};
|
||||
|
||||
const { hovering, bind } = useHovering();
|
||||
|
||||
const changeMuteDesc = isMuted ? 'Unmute' : 'Mute';
|
||||
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
|
||||
return (
|
||||
<Box
|
||||
width="100%"
|
||||
onClick={onClick}
|
||||
bg={
|
||||
(notification ? notification?.notification?.read : false)
|
||||
? "washedGray"
|
||||
: "washedBlue"
|
||||
}
|
||||
borderRadius={2}
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr 200px"
|
||||
gridTemplateColumns={["1fr 24px", "1fr 200px"]}
|
||||
gridTemplateRows="auto"
|
||||
gridTemplateAreas="'header actions' 'main main'"
|
||||
pb={2}
|
||||
p={2}
|
||||
m={2}
|
||||
{...bind}
|
||||
>
|
||||
{children}
|
||||
<Row gapX="2" p="2" pt='3' gridArea="actions" justifyContent="flex-end" opacity={[1, hovering ? 1 : 0]}>
|
||||
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute} backgroundColor="transparent">
|
||||
{changeMuteDesc}
|
||||
</StatelessAsyncAction>
|
||||
{!props.archived && (
|
||||
<StatelessAsyncAction name={time.toString()} onClick={onArchive} backgroundColor="transparent">
|
||||
Dismiss
|
||||
<Row
|
||||
alignItems="flex-start"
|
||||
gapX="2"
|
||||
gridArea="actions"
|
||||
justifyContent="flex-end"
|
||||
opacity={[1, (hovering || IS_MOBILE) ? 1 : 0]}
|
||||
>
|
||||
{time && notification && (
|
||||
<StatelessAsyncAction
|
||||
name={time.toString()}
|
||||
borderRadius={1}
|
||||
onClick={onArchive}
|
||||
backgroundColor="white"
|
||||
>
|
||||
<Icon lineHeight="24px" size={16} icon="X" />
|
||||
</StatelessAsyncAction>
|
||||
)}
|
||||
</Row>
|
||||
@ -110,23 +137,18 @@ export function Notification(props: NotificationProps) {
|
||||
const { notification, associations, archived } = props;
|
||||
const { read, contents, time } = notification.notification;
|
||||
|
||||
const Wrapper = ({ children }) => (
|
||||
<NotificationWrapper
|
||||
archived={archived}
|
||||
notif={notification}
|
||||
time={props.time}
|
||||
api={props.api}
|
||||
>
|
||||
{children}
|
||||
</NotificationWrapper>
|
||||
);
|
||||
const wrapperProps = {
|
||||
notification,
|
||||
time: props.time,
|
||||
api: props.api,
|
||||
};
|
||||
|
||||
if ('graph' in notification.index) {
|
||||
if ("graph" in notification.index) {
|
||||
const index = notification.index.graph;
|
||||
const c: GraphNotificationContents = (contents as any).graph;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<NotificationWrapper {...wrapperProps}>
|
||||
<GraphNotification
|
||||
api={props.api}
|
||||
index={index}
|
||||
@ -136,14 +158,14 @@ export function Notification(props: NotificationProps) {
|
||||
timebox={props.time}
|
||||
time={time}
|
||||
/>
|
||||
</Wrapper>
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
if ('group' in notification.index) {
|
||||
if ("group" in notification.index) {
|
||||
const index = notification.index.group;
|
||||
const c: GroupNotificationContents = (contents as any).group;
|
||||
return (
|
||||
<Wrapper>
|
||||
<NotificationWrapper {...wrapperProps}>
|
||||
<GroupNotification
|
||||
api={props.api}
|
||||
index={index}
|
||||
@ -153,7 +175,7 @@ export function Notification(props: NotificationProps) {
|
||||
archived={archived}
|
||||
time={time}
|
||||
/>
|
||||
</Wrapper>
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ import _ from 'lodash';
|
||||
import { Link, Switch, Route } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Box, Col, Text, Row } from '@tlon/indigo-react';
|
||||
import { Box, Icon, Col, Text, Row } from '@tlon/indigo-react';
|
||||
|
||||
import { Body } from '~/views/components/Body';
|
||||
import { PropFunc } from '~/types/util';
|
||||
@ -15,6 +15,7 @@ import { useTutorialModal } from '~/views/components/useTutorialModal';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import {StatelessAsyncAction} from '~/views/components/StatelessAsyncAction';
|
||||
|
||||
const baseUrl = '/~notifications';
|
||||
|
||||
@ -46,8 +47,8 @@ export default function NotificationsScreen(props: any): ReactElement {
|
||||
const onSubmit = async ({ groups } : NotificationFilter) => {
|
||||
setFilter({ groups });
|
||||
};
|
||||
const onReadAll = useCallback(() => {
|
||||
props.api.hark.readAll();
|
||||
const onReadAll = useCallback(async () => {
|
||||
await props.api.hark.readAll();
|
||||
}, []);
|
||||
const groupFilterDesc =
|
||||
filter.groups.length === 0
|
||||
@ -81,53 +82,25 @@ export default function NotificationsScreen(props: any): ReactElement {
|
||||
borderBottomColor="lightGray"
|
||||
>
|
||||
|
||||
<Text ref={anchorRef}>Notifications</Text>
|
||||
<Text fontWeight="bold" fontSize="2" lineHeight="1" ref={anchorRef}>
|
||||
Notifications
|
||||
</Text>
|
||||
<Row
|
||||
justifyContent="space-between"
|
||||
gapX="3"
|
||||
>
|
||||
<Box
|
||||
mr="1"
|
||||
<StatelessAsyncAction
|
||||
overflow="hidden"
|
||||
color="black"
|
||||
onClick={onReadAll}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Text mr="1" color="blue">
|
||||
Mark All Read
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Dropdown
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<Col
|
||||
p="2"
|
||||
backgroundColor="white"
|
||||
border={1}
|
||||
borderRadius={1}
|
||||
borderColor="lightGray"
|
||||
gapY="2"
|
||||
>
|
||||
<FormikOnBlur
|
||||
initialValues={filter}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<GroupSearch
|
||||
id="groups"
|
||||
label="Filter Groups"
|
||||
caption="Only show notifications from this group"
|
||||
/>
|
||||
</FormikOnBlur>
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
Mark All Read
|
||||
</StatelessAsyncAction>
|
||||
<Link to="/~settings#notifications">
|
||||
<Box>
|
||||
<Text mr="1" gray>
|
||||
Filter:
|
||||
</Text>
|
||||
<Text>{groupFilterDesc}</Text>
|
||||
<Icon lineHeight="1" icon="Adjust" />
|
||||
</Box>
|
||||
</Dropdown>
|
||||
</Link>
|
||||
</Row>
|
||||
</Row>
|
||||
{!view && <Inbox
|
||||
|
@ -64,20 +64,20 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
|
||||
{contact?.cover ? (
|
||||
<div>
|
||||
{editCover ? (
|
||||
<ImageInput id='cover' marginTop='-8px' />
|
||||
<ImageInput id='cover' marginTop='-8px' width='288px' />
|
||||
) : (
|
||||
<Row>
|
||||
<Button mr='2' onClick={() => setEditCover(true)}>
|
||||
Replace Header
|
||||
</Button>
|
||||
<Button onClick={(e) => handleClear(e)}>
|
||||
<Button onClick={e => handleClear(e)}>
|
||||
{removedCoverLabel}
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ImageInput id='cover' marginTop='-8px' />
|
||||
<ImageInput id='cover' marginTop='-8px' width='288px' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -134,7 +134,13 @@ export function ProfileActions(props: any): ReactElement {
|
||||
history.push(`/~profile/${ship}/edit`);
|
||||
}}
|
||||
>
|
||||
Edit {isPublic ? 'Public' : 'Private'} Profile
|
||||
Edit
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
display={['none','inline']}>
|
||||
{isPublic ? ' Public' : ' Private'} Profile
|
||||
</Text>
|
||||
</Text>
|
||||
<SetStatusBarModal
|
||||
isControl
|
||||
@ -183,7 +189,7 @@ export function Profile(props: any): ReactElement | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<Center p={[0, 4]} height='100%' width='100%'>
|
||||
<Center p={[3, 4]} height='100%' width='100%'>
|
||||
<Box maxWidth='600px' width='100%' position='relative'>
|
||||
{ isEdit ? (
|
||||
<EditProfile
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { StoreState } from '~/logic/store/type';
|
||||
import { Association } from '@urbit/api';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { RouteComponentProps, useLocation } from 'react-router-dom';
|
||||
import { NotebookRoutes } from './components/NotebookRoutes';
|
||||
|
||||
type PublishResourceProps = StoreState & {
|
||||
@ -17,9 +17,21 @@ export function PublishResource(props: PublishResourceProps) {
|
||||
const { association, api, baseUrl, notebooks } = props;
|
||||
const rid = association.resource;
|
||||
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 (
|
||||
<Box height="100%" width="100%" overflowY="auto">
|
||||
<Box ref={scrollRef} height="100%" width="100%" overflowY="auto">
|
||||
<NotebookRoutes
|
||||
api={api}
|
||||
ship={ship}
|
||||
|
@ -94,12 +94,6 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
|
||||
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 (
|
||||
<Box
|
||||
@ -112,7 +106,6 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
width="100%"
|
||||
gridRowGap={4}
|
||||
mx="auto"
|
||||
ref={windowRef}
|
||||
>
|
||||
<Link to={rootUrl}>
|
||||
<Text>{'<- Notebook Index'}</Text>
|
||||
|
@ -49,20 +49,20 @@
|
||||
font-family: 'Source Code Pro';
|
||||
}
|
||||
|
||||
.publish .CodeMirror-selected { background:#BAE3FE !important; color: black; }
|
||||
.publish .CodeMirror-selected { background:#BAE3FE !important; color: inherit; }
|
||||
|
||||
.publish .cm-s-tlon span { font-family: "Source Code Pro"}
|
||||
.publish .cm-s-tlon span.cm-meta { color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-number { color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-keyword { line-height: 1em; font-weight: bold; color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-atom { font-weight: bold; color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-def { color: black; }
|
||||
.publish .cm-s-tlon span.cm-variable { color: black; }
|
||||
.publish .cm-s-tlon span.cm-variable-2 { color: black; }
|
||||
.publish .cm-s-tlon span.cm-variable-3, .publish .cm-s-tlon span.cm-type { color: black; }
|
||||
.publish .cm-s-tlon span.cm-property { color: black; }
|
||||
.publish .cm-s-tlon span.cm-operator { color: black; }
|
||||
.publish .cm-s-tlon span.cm-comment { color: black; background-color: var(--light-gray); display: inline-block; border-radius: 2px;}
|
||||
.publish .cm-s-tlon span.cm-def { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-variable { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-variable-2 { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-variable-3, .publish .cm-s-tlon span.cm-type { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-property { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-operator { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-comment { color: inherit; background-color: var(--light-gray); display: inline-block; border-radius: 2px;}
|
||||
.publish .cm-s-tlon span.cm-string { color: var(--dark-gray); }
|
||||
.publish .cm-s-tlon span.cm-string-2 { color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-qualifier { color: #555; }
|
||||
|
@ -1,32 +1,36 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Row,
|
||||
Label,
|
||||
Col,
|
||||
ManagedRadioButtonField as Radio,
|
||||
ManagedRadioButtonField as Radio
|
||||
} from '@tlon/indigo-react';
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { ImageInput } from '~/views/components/ImageInput';
|
||||
import { ColorInput } from '~/views/components/ColorInput';
|
||||
import { StorageState } from '~/types';
|
||||
|
||||
export type BgType = 'none' | 'url' | 'color';
|
||||
|
||||
export function BackgroundPicker({
|
||||
bgType,
|
||||
bgUrl,
|
||||
api,
|
||||
api
|
||||
}: {
|
||||
bgType: BgType;
|
||||
bgUrl?: string;
|
||||
api: GlobalApi;
|
||||
}): ReactElement {
|
||||
const rowSpace = { my: 0, alignItems: 'center' };
|
||||
const colProps = { my: 3, mr: 4, gapY: 1 };
|
||||
const colProps = {
|
||||
my: 3,
|
||||
mr: 4,
|
||||
gapY: 1,
|
||||
minWidth: '266px',
|
||||
width: ['100%', '288px']
|
||||
};
|
||||
return (
|
||||
<Col>
|
||||
<Label>Landscape Background</Label>
|
||||
@ -40,7 +44,7 @@ export function BackgroundPicker({
|
||||
id="bgUrl"
|
||||
placeholder="Drop or upload a file, or paste a link here"
|
||||
name="bgUrl"
|
||||
url={bgUrl || ""}
|
||||
url={bgUrl || ''}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@ -48,13 +52,13 @@ export function BackgroundPicker({
|
||||
<Col {...colProps}>
|
||||
<Radio mb="1" label="Color" id="color" name="bgType" />
|
||||
<Text ml="5" gray>Set a hex-based background</Text>
|
||||
<ColorInput placeholder="FFFFFF" ml="5" id="bgColor" />
|
||||
<ColorInput placeholder="FFFFFF" ml="5" id="bgColor" />
|
||||
</Col>
|
||||
</Row>
|
||||
<Radio
|
||||
my="3"
|
||||
caption="Your home screen will simply render as its respective day/night mode color"
|
||||
name="bgType"
|
||||
name="bgType"
|
||||
label="None"
|
||||
id="none" />
|
||||
</Col>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -10,11 +10,19 @@ import GlobalApi from "~/logic/api/global";
|
||||
import useHarkState from "~/logic/state/hark";
|
||||
import _ from "lodash";
|
||||
import {AsyncButton} from "~/views/components/AsyncButton";
|
||||
import {GroupChannelPicker} from "./GroupChannelPicker";
|
||||
import {isWatching} from "~/logic/lib/hark";
|
||||
|
||||
interface FormSchema {
|
||||
mentions: boolean;
|
||||
dnd: boolean;
|
||||
watchOnSelf: boolean;
|
||||
graph: {
|
||||
[rid: string]: boolean;
|
||||
};
|
||||
groups: {
|
||||
[rid: string]: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationPreferences(props: {
|
||||
@ -23,6 +31,7 @@ export function NotificationPreferences(props: {
|
||||
const { api } = props;
|
||||
const dnd = useHarkState(state => state.doNotDisturb);
|
||||
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
|
||||
const groupConfig = useHarkState(s => s.notificationsGroupConfig);
|
||||
const initialValues = {
|
||||
mentions: graphConfig.mentions,
|
||||
dnd: dnd,
|
||||
@ -41,6 +50,16 @@ export function NotificationPreferences(props: {
|
||||
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
|
||||
promises.push(api.hark.setDoNotDisturb(values.dnd))
|
||||
}
|
||||
_.forEach(values.graph, (listen: boolean, graph: string) => {
|
||||
if(listen !== isWatching(graphConfig, graph)) {
|
||||
promises.push(api.hark[listen ? "listenGraph" : "ignoreGraph"](graph, "/"))
|
||||
}
|
||||
});
|
||||
_.forEach(values.groups, (listen: boolean, group: string) => {
|
||||
if(listen !== groupConfig.includes(group)) {
|
||||
promises.push(api.hark[listen ? "listenGroup" : "ignoreGroup"](group));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
actions.setStatus({ success: null });
|
||||
@ -81,6 +100,15 @@ export function NotificationPreferences(props: {
|
||||
id="mentions"
|
||||
caption="Notify me if someone mentions my @p in a channel I've joined"
|
||||
/>
|
||||
<Col gapY="3">
|
||||
<Text lineHeight="tall">
|
||||
Activity
|
||||
</Text>
|
||||
<Text gray>
|
||||
Set which groups will send you notifications.
|
||||
</Text>
|
||||
<GroupChannelPicker />
|
||||
</Col>
|
||||
<AsyncButton primary width="fit-content">
|
||||
Save
|
||||
</AsyncButton>
|
||||
|
@ -83,7 +83,7 @@ export default function S3Form(props: S3FormProps): ReactElement {
|
||||
style={{ textDecoration: 'none' }}
|
||||
borderBottom='1'
|
||||
ml='1'
|
||||
href='https://urbit.org/using/operations/using-your-ship/#bucket-setup'
|
||||
href='https://urbit.org/using/os/s3/'
|
||||
>
|
||||
Learn more
|
||||
</Anchor>
|
||||
|
@ -9,16 +9,16 @@ import { Group } from '@urbit/api';
|
||||
import { uxToHex, cite, useShowNickname, deSig } from '~/logic/lib/util';
|
||||
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
|
||||
import useLocalState from "~/logic/state/local";
|
||||
import OverlaySigil from './OverlaySigil';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import Timestamp from './Timestamp';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import { useCopy } from '~/logic/lib/useCopy';
|
||||
import ProfileOverlay from './ProfileOverlay';
|
||||
import {PropFunc} from '~/types';
|
||||
import { PropFunc } from '~/types';
|
||||
|
||||
interface AuthorProps {
|
||||
ship: string;
|
||||
date: number;
|
||||
date?: number;
|
||||
showImage?: boolean;
|
||||
children?: ReactNode;
|
||||
unread?: boolean;
|
||||
@ -61,6 +61,7 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
|
||||
const { hideAvatars } = useSettingsState(selectCalmState);
|
||||
const name = showNickname && contact ? contact.nickname : cite(ship);
|
||||
const stamp = moment(date);
|
||||
const { copyDisplay, doCopy, didCopy } = useCopy(`~${ship}`, name);
|
||||
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
|
||||
@ -108,13 +109,17 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
|
||||
ml={showImage ? 2 : 0}
|
||||
color='black'
|
||||
fontSize='1'
|
||||
cursor='pointer'
|
||||
lineHeight='tall'
|
||||
fontFamily={showNickname ? 'sans' : 'mono'}
|
||||
fontWeight={showNickname ? '500' : '400'}
|
||||
mr={showNickname ? 0 : "2px"}
|
||||
mt={showNickname ? 0 : "0px"}
|
||||
onClick={doCopy}
|
||||
>
|
||||
{name}
|
||||
{copyDisplay}
|
||||
</Box>
|
||||
{ !dontShowTime && (
|
||||
{ !dontShowTime && time && (
|
||||
<Timestamp
|
||||
relative={isRelativeTime}
|
||||
stamp={stamp}
|
||||
|
@ -15,6 +15,7 @@ import { getLatestCommentRevision } from '~/logic/lib/publish';
|
||||
import {useCopy} from '~/logic/lib/useCopy';
|
||||
import { getPermalinkForGraph} from '~/logic/lib/permalinks';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import {GraphContentWide} from '../landscape/components/Graph/GraphContentWide';
|
||||
|
||||
const ClickBox = styled(Box)`
|
||||
cursor: pointer;
|
||||
@ -95,25 +96,22 @@ export function CommentItem(props: CommentItemProps): ReactElement {
|
||||
unread={props.unread}
|
||||
group={group}
|
||||
>
|
||||
<Row px="2" gapX="2" alignItems="center">
|
||||
<Row px="2" gapX="2" height="18px">
|
||||
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
|
||||
{adminLinks}
|
||||
</Row>
|
||||
</Author>
|
||||
</Row>
|
||||
<Box
|
||||
<GraphContentWide
|
||||
borderRadius="1"
|
||||
p="1"
|
||||
mb="1"
|
||||
backgroundColor={props.highlighted ? 'washedBlue' : 'white'}
|
||||
>
|
||||
<MentionText
|
||||
transcluded={0}
|
||||
api={api}
|
||||
group={group}
|
||||
content={post?.contents}
|
||||
/>
|
||||
</Box>
|
||||
transcluded={0}
|
||||
api={api}
|
||||
post={post}
|
||||
showOurContact
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
175
pkg/interface/src/views/components/FormGroup.tsx
Normal file
175
pkg/interface/src/views/components/FormGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState, useLayoutEffect, ReactElement } from 'react';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Box, Text, Row, Col } from '@tlon/indigo-react';
|
||||
import { Associations, Groups } from '@urbit/api';
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { MetadataIcon } from '../landscape/components/MetadataIcon';
|
||||
import { JoinGroup } from '../landscape/components/JoinGroup';
|
||||
@ -23,27 +22,12 @@ export function GroupLink(
|
||||
const name = resource.slice(6);
|
||||
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
|
||||
const associations = useMetadataState(state => state.associations);
|
||||
|
||||
const { save, restore } = useVirtual();
|
||||
const history = useHistory();
|
||||
const joined = resource in associations.groups;
|
||||
|
||||
const { save, restore } = useVirtual();
|
||||
|
||||
const { modal, showModal } = useModal({
|
||||
modal:
|
||||
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}
|
||||
/>
|
||||
)
|
||||
modal: <JoinGroup api={api} autojoin={name} />
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -72,7 +56,9 @@ export function GroupLink(
|
||||
alignItems="center"
|
||||
py="2"
|
||||
pr="2"
|
||||
onClick={showModal}
|
||||
onClick={
|
||||
joined ? () => history.push(`/~landscape/ship/${name}`) : showModal
|
||||
}
|
||||
cursor='pointer'
|
||||
opacity={preview ? '1' : '0.6'}
|
||||
>
|
||||
|
@ -7,11 +7,11 @@ import {
|
||||
Row,
|
||||
Button,
|
||||
Label,
|
||||
ErrorLabel,
|
||||
BaseInput
|
||||
BaseInput,
|
||||
Text,
|
||||
Icon
|
||||
} from '@tlon/indigo-react';
|
||||
|
||||
import { StorageState } from '~/types';
|
||||
import useStorage from '~/logic/lib/useStorage';
|
||||
|
||||
type ImageInputProps = Parameters<typeof Box>[0] & {
|
||||
@ -20,13 +20,100 @@ type ImageInputProps = Parameters<typeof Box>[0] & {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const prompt = (field, uploading, meta, clickUploadButton) => {
|
||||
if (!field.value && !uploading && meta.error === undefined) {
|
||||
return (
|
||||
<Text
|
||||
black
|
||||
fontWeight='500'
|
||||
position='absolute'
|
||||
left={2}
|
||||
top={2}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
Paste a link here, or{' '}
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
color='blue'
|
||||
style={{ pointerEvents: 'all' }}
|
||||
onClick={clickUploadButton}
|
||||
>
|
||||
upload
|
||||
</Text>{' '}
|
||||
a file
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const uploadingStatus = (uploading, meta) => {
|
||||
if (uploading && meta.error === undefined) {
|
||||
return (
|
||||
<Text position='absolute' left={2} top={2} gray>
|
||||
Uploading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorRetry = (meta, uploading, clickUploadButton) => {
|
||||
if (meta.error !== undefined) {
|
||||
return (
|
||||
<Text
|
||||
position='absolute'
|
||||
left={2}
|
||||
top={2}
|
||||
color='red'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{meta.error}{', '}please{' '}
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
color='blue'
|
||||
style={{ pointerEvents: 'all' }}
|
||||
onClick={clickUploadButton}
|
||||
>
|
||||
retry
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clearButton = (field, uploading, clearEvt) => {
|
||||
if (field.value && !uploading) {
|
||||
return (
|
||||
<Box
|
||||
position='absolute'
|
||||
right={0}
|
||||
top={0}
|
||||
px={1}
|
||||
height='100%'
|
||||
cursor='pointer'
|
||||
onClick={clearEvt}
|
||||
backgroundColor='white'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
borderRadius='0 4px 4px 0'
|
||||
border='1px solid'
|
||||
borderColor='lightGray'
|
||||
>
|
||||
<Icon icon='X' />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
const { id, label, caption, placeholder } = props;
|
||||
|
||||
const { id, label, caption } = props;
|
||||
const { uploadDefault, canUpload, uploading } = useStorage();
|
||||
|
||||
const [field, meta, { setValue, setError }] = useField(id);
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onImageUpload = useCallback(async () => {
|
||||
@ -43,10 +130,14 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
}
|
||||
}, [ref.current, uploadDefault, canUpload, setValue]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
const clickUploadButton = useCallback(() => {
|
||||
ref.current?.click();
|
||||
}, [ref]);
|
||||
|
||||
const clearEvt = useCallback(() => {
|
||||
setValue('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" {...props}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
@ -55,25 +146,25 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
{caption}
|
||||
</Label>
|
||||
) : null}
|
||||
<Row mt="2" alignItems="flex-end">
|
||||
<Input
|
||||
type={'text'}
|
||||
hasError={meta.touched && meta.error !== undefined}
|
||||
placeholder={placeholder}
|
||||
{...field}
|
||||
/>
|
||||
<Row mt="2" alignItems="flex-end" position='relative' width='100%'>
|
||||
{prompt(field, uploading, meta, clickUploadButton)}
|
||||
{clearButton(field, uploading, clearEvt)}
|
||||
{uploadingStatus(uploading, meta)}
|
||||
{errorRetry(meta, uploading, clickUploadButton)}
|
||||
<Box background='white' borderRadius={2} width='100%'>
|
||||
<Input
|
||||
width='100%'
|
||||
type={'text'}
|
||||
hasError={meta.touched && meta.error !== undefined}
|
||||
{...field}
|
||||
/>
|
||||
</Box>
|
||||
{canUpload && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
ml={1}
|
||||
border={1}
|
||||
borderColor="lightGray"
|
||||
onClick={onClick}
|
||||
flexShrink={0}
|
||||
>
|
||||
{uploading ? 'Uploading' : 'Upload'}
|
||||
</Button>
|
||||
display='none'
|
||||
onClick={clickUploadButton}
|
||||
/>
|
||||
<BaseInput
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
@ -85,9 +176,6 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
<ErrorLabel mt="2" hasError={Boolean(meta.touched && meta.error)}>
|
||||
{meta.error}
|
||||
</ErrorLabel>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -1,79 +1,329 @@
|
||||
import React, { ReactElement, ReactNode } from 'react';
|
||||
import { Text, Box, Icon, Row } from '@tlon/indigo-react';
|
||||
import React, { ReactElement, ReactNode, useCallback } from "react";
|
||||
import {
|
||||
Text,
|
||||
Box,
|
||||
Icon,
|
||||
Row,
|
||||
LoadingSpinner,
|
||||
Button,
|
||||
} from "@tlon/indigo-react";
|
||||
import { css } from "@styled-system/css";
|
||||
import _ from "lodash";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { MetadataUpdatePreview, JoinProgress, Invite, JoinRequest } from '@urbit/api';
|
||||
import { GroupSummary } from '~/views/landscape/components/GroupSummary';
|
||||
import { InviteSkeleton } from './InviteSkeleton';
|
||||
import { JoinSkeleton } from './JoinSkeleton';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { cite, isDm } from "~/logic/lib/util";
|
||||
import {
|
||||
MetadataUpdatePreview,
|
||||
joinProgress,
|
||||
JoinProgress,
|
||||
Invite,
|
||||
JoinRequest,
|
||||
resourceFromPath,
|
||||
Metadata,
|
||||
} from "@urbit/api";
|
||||
import { GroupSummary } from "~/views/landscape/components/GroupSummary";
|
||||
import { NotificationWrapper } from "~/views/apps/notifications/notification";
|
||||
import { Header } from "~/views/apps/notifications/header";
|
||||
import { InviteSkeleton } from "./InviteSkeleton";
|
||||
import { JoinSkeleton } from "./JoinSkeleton";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { PropFunc } from "~/types";
|
||||
import { MetadataIcon } from "~/views/landscape/components/MetadataIcon";
|
||||
import { useContact } from "~/logic/state/contact";
|
||||
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
|
||||
import useGroupState, {useGroup} from "~/logic/state/group";
|
||||
import useContactState from "~/logic/state/contact";
|
||||
import useMetadataState, {useAssocForGraph} from "~/logic/state/metadata";
|
||||
import useGraphState from "~/logic/state/graph";
|
||||
import { useRunIO } from "~/logic/lib/useRunIO";
|
||||
import { StatelessAsyncButton } from "../StatelessAsyncButton";
|
||||
import styled from "styled-components";
|
||||
|
||||
interface GroupInviteProps {
|
||||
preview: MetadataUpdatePreview;
|
||||
preview?: MetadataUpdatePreview;
|
||||
status?: JoinRequest;
|
||||
app?: string;
|
||||
uid?: string;
|
||||
invite?: Invite;
|
||||
resource: string;
|
||||
api: GlobalApi;
|
||||
onAccept: () => Promise<any>;
|
||||
onDecline: () => Promise<any>;
|
||||
}
|
||||
|
||||
export function GroupInvite(props: GroupInviteProps): ReactElement {
|
||||
const { resource, api, preview, invite, status, onAccept, onDecline } = props;
|
||||
const { metadata, members } = props.preview;
|
||||
function Elbow(
|
||||
props: { size?: number; color?: string } & PropFunc<typeof Box>
|
||||
) {
|
||||
const { size = 12, color = "lightGray", ...rest } = props;
|
||||
|
||||
let inner: ReactNode = null;
|
||||
let Outer: (p: { children: ReactNode }) => JSX.Element = p => (
|
||||
<>{p.children}</>
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
overflow="hidden"
|
||||
width={size}
|
||||
height={size}
|
||||
position="relative"
|
||||
>
|
||||
<Box
|
||||
border="2px solid"
|
||||
borderRadius={3}
|
||||
borderColor={color}
|
||||
position="absolute"
|
||||
left="0px"
|
||||
bottom="0px"
|
||||
width={size * 2}
|
||||
height={size * 2}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const description: string[] = [
|
||||
"Contacting host...",
|
||||
"Retrieving data...",
|
||||
"Finished join",
|
||||
"Unable to join, you do not have the correct permissions",
|
||||
"Internal error, please file an issue",
|
||||
];
|
||||
|
||||
function inviteUrl(hidden: boolean, resource: string, metadata?: Metadata) {
|
||||
if (!hidden) {
|
||||
return `/~landscape${resource}`;
|
||||
}
|
||||
|
||||
if (metadata?.config.graph === "chat") {
|
||||
return `/~landscape/messages/resource/${metadata?.config?.graph}${resource}`;
|
||||
} else {
|
||||
return `/~landscape/home/resource/${metadata?.config?.graph}${resource}`;
|
||||
}
|
||||
}
|
||||
function InviteMetadata(props: {
|
||||
preview?: MetadataUpdatePreview;
|
||||
resource: string;
|
||||
}) {
|
||||
const { resource, preview } = props;
|
||||
const { ship, name } = resourceFromPath(resource);
|
||||
const dm = isDm(resource);
|
||||
if (dm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = (children: ReactNode) => (
|
||||
<Row overflow="hidden" height="4" gapX="2" alignItems="center">
|
||||
{children}
|
||||
</Row>
|
||||
);
|
||||
|
||||
if (status) {
|
||||
inner = (
|
||||
<Text mr="1">
|
||||
You are joining <Text fontWeight="medium">{metadata.title}</Text>
|
||||
</Text>
|
||||
);
|
||||
Outer = ({ children }) => (
|
||||
<JoinSkeleton resource={resource} api={api} gapY="3" status={status}>
|
||||
{children}
|
||||
</JoinSkeleton>
|
||||
);
|
||||
} else if (invite) {
|
||||
Outer = ({ children }) => (
|
||||
<InviteSkeleton
|
||||
onDecline={onDecline}
|
||||
onAccept={onAccept}
|
||||
acceptDesc="Join Group"
|
||||
declineDesc="Decline Invitation"
|
||||
gapY="3"
|
||||
>
|
||||
{children}
|
||||
</InviteSkeleton>
|
||||
);
|
||||
inner = (
|
||||
if (preview) {
|
||||
const { title } = preview.metadata;
|
||||
const { members } = preview;
|
||||
return container(
|
||||
<>
|
||||
<Text mr="1" mono>
|
||||
{cite(`~${invite!.ship}`)}
|
||||
<MetadataIcon height={4} width={4} metadata={preview.metadata} />
|
||||
<Text fontWeight="medium">{title}</Text>
|
||||
<Text gray fontWeight="medium">
|
||||
{members} Member{members > 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Text mr="1">invited you to </Text>
|
||||
<Text fontWeight="medium">{metadata.title}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Outer>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" mr={2} icon="Bullet" color="blue" />
|
||||
{inner}
|
||||
</Row>
|
||||
<Box px="4">
|
||||
<GroupSummary
|
||||
gray
|
||||
metadata={metadata}
|
||||
memberCount={members}
|
||||
channelCount={preview?.['channel-count']}
|
||||
/>
|
||||
</Box>
|
||||
</Outer>
|
||||
|
||||
return container(
|
||||
<>
|
||||
<Text whiteSpace="nowrap" textOverflow="ellipsis" ml="1px" mb="2px" mono>
|
||||
{cite(ship)}/{name}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteStatus(props: { status?: JoinRequest }) {
|
||||
const { status } = props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = status && joinProgress.indexOf(status.progress);
|
||||
const desc = _.isNumber(current) && description[current];
|
||||
return (
|
||||
<Row gapX="1" alignItems="center" height={4}>
|
||||
{ status.progress === 'done' ? <Icon icon="Checkmark" /> : <LoadingSpinner dark /> }
|
||||
<Text gray>{desc}</Text>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInviteAccept(
|
||||
resource: string,
|
||||
api: GlobalApi,
|
||||
app?: string,
|
||||
uid?: string
|
||||
) {
|
||||
const { ship, name } = resourceFromPath(resource);
|
||||
const history = useHistory();
|
||||
const associations = useMetadataState((s) => s.associations);
|
||||
const groups = useGroupState((s) => s.groups);
|
||||
const graphKeys = useGraphState((s) => s.graphKeys);
|
||||
|
||||
const waiter = useWaitForProps({ associations, graphKeys, groups });
|
||||
return useRunIO<void, boolean>(
|
||||
async () => {
|
||||
if (!(app && uid)) {
|
||||
return false;
|
||||
}
|
||||
if (resource in groups) {
|
||||
await api.invite.decline(app, uid);
|
||||
return false;
|
||||
}
|
||||
|
||||
await api.groups.join(ship, name);
|
||||
await api.invite.accept(app, uid);
|
||||
await waiter((p) => {
|
||||
return (
|
||||
(resource in p.groups &&
|
||||
resource in (p.associations?.graph ?? {}) &&
|
||||
p.graphKeys.has(resource.slice(7))) ||
|
||||
resource in (p.associations?.groups ?? {})
|
||||
);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
(success: boolean) => {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
const redir = inviteUrl(
|
||||
groups?.[resource]?.hidden,
|
||||
resource,
|
||||
associations?.graph?.[resource]?.metadata
|
||||
);
|
||||
if (redir) {
|
||||
// weird race condition
|
||||
setTimeout(() => {
|
||||
history.push(redir);
|
||||
}, 200);
|
||||
}
|
||||
},
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
function InviteActions(props: {
|
||||
status?: JoinRequest;
|
||||
resource: string;
|
||||
api: GlobalApi;
|
||||
app?: string;
|
||||
uid?: string;
|
||||
}) {
|
||||
const { resource, api, app, uid } = props;
|
||||
const inviteAccept = useInviteAccept(resource, api, app, uid);
|
||||
|
||||
const inviteDecline = useCallback(async () => {
|
||||
if (!(app && uid)) {
|
||||
return;
|
||||
}
|
||||
await api.invite.decline(app, uid);
|
||||
}, [app, uid]);
|
||||
|
||||
const hideJoin = useCallback(async () => {
|
||||
await api.groups.hide(resource);
|
||||
}, [api, resource]);
|
||||
|
||||
const { status } = props;
|
||||
if (status) {
|
||||
if(status.progress === 'done') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Row gapX="2" alignItems="center" height={4}>
|
||||
<StatelessAsyncButton
|
||||
height={4}
|
||||
backgroundColor="white"
|
||||
onClick={hideJoin}
|
||||
>
|
||||
Cancel
|
||||
</StatelessAsyncButton>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gapX="2" alignItems="center" height={4}>
|
||||
<StatelessAsyncButton
|
||||
color="blue"
|
||||
height={4}
|
||||
backgroundColor="white"
|
||||
onClick={inviteAccept}
|
||||
>
|
||||
Accept
|
||||
</StatelessAsyncButton>
|
||||
<StatelessAsyncButton
|
||||
height={4}
|
||||
backgroundColor="white"
|
||||
onClick={inviteDecline}
|
||||
>
|
||||
Decline
|
||||
</StatelessAsyncButton>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveStyle = ({ gapXY = 0 as number | number[] }) => {
|
||||
return css({
|
||||
flexDirection: ["column", "row"],
|
||||
"& > *": {
|
||||
marginTop: _.isArray(gapXY) ? [gapXY[0], 0] : [gapXY, 0],
|
||||
marginLeft: _.isArray(gapXY) ? [0, ...gapXY.slice(1)] : [0, gapXY],
|
||||
},
|
||||
"& > :first-child": {
|
||||
marginTop: 0,
|
||||
marginLeft: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
const ResponsiveRow = styled(Row)(responsiveStyle);
|
||||
export function GroupInvite(props: GroupInviteProps): ReactElement {
|
||||
const { resource, api, preview, invite, status, app, uid } = props;
|
||||
const dm = isDm(resource);
|
||||
const history = useHistory();
|
||||
|
||||
const invitedTo = dm ? "DM" : "group";
|
||||
const graphAssoc = useAssocForGraph(resource);
|
||||
|
||||
|
||||
const headerProps = status
|
||||
? { description: `You are joining a ${invitedTo}` }
|
||||
: { description: `invited you to a ${invitedTo}`, authors: [invite!.ship] };
|
||||
|
||||
const onClick = () => {
|
||||
if(status?.progress === 'done') {
|
||||
const redir = inviteUrl(app !== 'groups', resource, graphAssoc?.metadata);
|
||||
if(redir) {
|
||||
history.push(redir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationWrapper api={api}>
|
||||
<Header content {...headerProps} />
|
||||
<Row onClick={onClick} height={[null, 4]} alignItems="flex-start" gridArea="main">
|
||||
<Elbow mx="2" />
|
||||
<ResponsiveRow
|
||||
gapXY={[1, 2]}
|
||||
height={[null, 4]}
|
||||
alignItems={["flex-start", "center"]}
|
||||
>
|
||||
<InviteMetadata preview={preview} resource={resource} />
|
||||
<InviteStatus status={status} />
|
||||
<InviteActions
|
||||
api={api}
|
||||
resource={resource}
|
||||
status={status}
|
||||
app={app}
|
||||
uid={uid}
|
||||
/>
|
||||
</ResponsiveRow>
|
||||
</Row>
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
|
@ -7,113 +7,26 @@ import {
|
||||
JoinRequests,
|
||||
Groups,
|
||||
Associations,
|
||||
JoinRequest,
|
||||
} from "@urbit/api";
|
||||
import { Invite } from "@urbit/api/invite";
|
||||
import { Text, Icon, Row } from "@tlon/indigo-react";
|
||||
|
||||
import { cite, useShowNickname } from "~/logic/lib/util";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { resourceFromPath } from "~/logic/lib/group";
|
||||
import { GroupInvite } from "./Group";
|
||||
import { InviteSkeleton } from "./InviteSkeleton";
|
||||
import { JoinSkeleton } from "./JoinSkeleton";
|
||||
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
|
||||
import useGroupState from "~/logic/state/group";
|
||||
import useContactState from "~/logic/state/contact";
|
||||
import useMetadataState from "~/logic/state/metadata";
|
||||
import useGraphState from "~/logic/state/graph";
|
||||
import { useRunIO } from "~/logic/lib/useRunIO";
|
||||
|
||||
interface InviteItemProps {
|
||||
invite?: Invite;
|
||||
resource: string;
|
||||
pendingJoin?: string;
|
||||
pendingJoin?: JoinRequest;
|
||||
app?: string;
|
||||
uid?: string;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function useInviteAccept(
|
||||
resource: string,
|
||||
api: GlobalApi,
|
||||
app?: string,
|
||||
uid?: string,
|
||||
invite?: Invite,
|
||||
) {
|
||||
const { ship, name } = resourceFromPath(resource);
|
||||
const history = useHistory();
|
||||
const associations = useMetadataState((s) => s.associations);
|
||||
const groups = useGroupState((s) => s.groups);
|
||||
const graphKeys = useGraphState((s) => s.graphKeys);
|
||||
|
||||
const waiter = useWaitForProps({ associations, graphKeys, groups });
|
||||
return useRunIO<void, boolean>(
|
||||
async () => {
|
||||
if (!(app && invite && uid)) {
|
||||
return false;
|
||||
}
|
||||
if (resource in groups) {
|
||||
await api.invite.decline(app, uid);
|
||||
return false;
|
||||
}
|
||||
|
||||
await api.groups.join(ship, name);
|
||||
await api.invite.accept(app, uid);
|
||||
await waiter((p) => {
|
||||
return (
|
||||
(resource in p.groups &&
|
||||
resource in (p.associations?.graph ?? {}) &&
|
||||
p.graphKeys.has(resource.slice(7))) ||
|
||||
resource in (p.associations?.groups ?? {})
|
||||
);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
(success: boolean) => {
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
if (groups?.[resource]?.hidden) {
|
||||
const { metadata } = associations.graph[resource];
|
||||
if (metadata && "graph" in metadata.config) {
|
||||
if (metadata.config.graph === "chat") {
|
||||
history.push(
|
||||
`/~landscape/messages/resource/${metadata.config.graph}${resource}`
|
||||
);
|
||||
} else {
|
||||
history.push(
|
||||
`/~landscape/home/resource/${metadata.config.graph}${resource}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("unknown metadata: ", metadata);
|
||||
}
|
||||
} else {
|
||||
history.push(`/~landscape${resource}`);
|
||||
}
|
||||
},
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteItem(props: InviteItemProps) {
|
||||
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
|
||||
const { pendingJoin, invite, resource, uid, app, api } = props;
|
||||
const { name } = resourceFromPath(resource);
|
||||
const contacts = useContactState((state) => state.contacts);
|
||||
const contact = contacts?.[`~${invite?.ship}`] ?? {};
|
||||
const showNickname = useShowNickname(contact);
|
||||
|
||||
const inviteAccept = useInviteAccept(resource, api, app, uid, invite);
|
||||
|
||||
const inviteDecline = useCallback(async () => {
|
||||
if (!(app && uid)) {
|
||||
return;
|
||||
}
|
||||
await api.invite.decline(app, uid);
|
||||
}, [app, uid]);
|
||||
|
||||
const handlers = { onAccept: inviteAccept, onDecline: inviteDecline };
|
||||
|
||||
useEffect(() => {
|
||||
if (!app || app === "groups") {
|
||||
@ -132,86 +45,17 @@ export function InviteItem(props: InviteItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
return (
|
||||
<GroupInvite
|
||||
resource={resource}
|
||||
api={api}
|
||||
preview={preview}
|
||||
invite={invite}
|
||||
status={pendingJoin}
|
||||
{...handlers}
|
||||
/>
|
||||
);
|
||||
} else if (invite && name.startsWith("dm--")) {
|
||||
return (
|
||||
<InviteSkeleton
|
||||
gapY="3"
|
||||
{...handlers}
|
||||
acceptDesc="Join DM"
|
||||
declineDesc="Decline DM"
|
||||
>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text
|
||||
mr="1"
|
||||
mono={!showNickname}
|
||||
fontWeight={showNickname ? "500" : "400"}
|
||||
>
|
||||
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
|
||||
</Text>
|
||||
<Text mr="1">invited you to a DM</Text>
|
||||
</Row>
|
||||
</InviteSkeleton>
|
||||
);
|
||||
} else if (status && name.startsWith("dm--")) {
|
||||
return (
|
||||
<JoinSkeleton api={api} resource={resource} status={status} gapY="3">
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text mr="1">Joining direct message...</Text>
|
||||
</Row>
|
||||
</JoinSkeleton>
|
||||
);
|
||||
} else if (invite) {
|
||||
return (
|
||||
<InviteSkeleton
|
||||
acceptDesc="Accept Invite"
|
||||
declineDesc="Decline Invite"
|
||||
resource={resource}
|
||||
{...handlers}
|
||||
gapY="3"
|
||||
>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text
|
||||
mr="1"
|
||||
mono={!showNickname}
|
||||
fontWeight={showNickname ? "500" : "400"}
|
||||
>
|
||||
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
|
||||
</Text>
|
||||
<Text mr="1">
|
||||
invited you to ~{invite.resource.ship}/{invite.resource.name}
|
||||
</Text>
|
||||
</Row>
|
||||
</InviteSkeleton>
|
||||
);
|
||||
} else if (pendingJoin) {
|
||||
const [, , ship, name] = resource.split("/");
|
||||
return (
|
||||
<JoinSkeleton api={api} resource={resource} status={pendingJoin}>
|
||||
<Row py="1" alignItems="center">
|
||||
<Icon display="block" color="blue" icon="Bullet" mr="2" />
|
||||
<Text mr="1">You are joining</Text>
|
||||
<Text mono>
|
||||
{cite(ship)}/{name}
|
||||
</Text>
|
||||
</Row>
|
||||
</JoinSkeleton>
|
||||
);
|
||||
return (
|
||||
<GroupInvite
|
||||
resource={resource}
|
||||
api={api}
|
||||
preview={preview}
|
||||
invite={invite}
|
||||
status={pendingJoin}
|
||||
uid={uid}
|
||||
app={app}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default InviteItem;
|
||||
|
@ -3,10 +3,10 @@ import _ from 'lodash';
|
||||
import { Text, Box } from '@tlon/indigo-react';
|
||||
import { Contact, Contacts, Content, Group } from '@urbit/api';
|
||||
import RichText from '~/views/components/RichText';
|
||||
import { cite, useShowNickname, uxToHex } from '~/logic/lib/util';
|
||||
import { cite, useShowNickname, uxToHex, deSig } from '~/logic/lib/util';
|
||||
import ProfileOverlay from '~/views/components/ProfileOverlay';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useContactState, {useContact} from '~/logic/state/contact';
|
||||
import {referenceToPermalink} from '~/logic/lib/permalinks';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
|
||||
@ -40,46 +40,28 @@ export function MentionText(props: MentionTextProps) {
|
||||
}
|
||||
|
||||
export function Mention(props: {
|
||||
contact: Contact;
|
||||
group: Group;
|
||||
scrollWindow?: HTMLElement;
|
||||
ship: string;
|
||||
first?: Boolean;
|
||||
api: any;
|
||||
}) {
|
||||
const { ship, scrollWindow, first, api, ...rest } = props;
|
||||
let { contact } = props;
|
||||
const contacts = useContactState(state => state.contacts);
|
||||
contact = contact?.color ? contact : contacts?.[`~${ship}`];
|
||||
const history = useHistory();
|
||||
const { ship, first, api, ...rest } = props;
|
||||
const contact = useContact(`~${deSig(ship)}`);
|
||||
const showNickname = useShowNickname(contact);
|
||||
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 (
|
||||
<Box position='relative' display='inline-block' cursor='pointer' {...rest}>
|
||||
<ProfileOverlay
|
||||
ship={ship}
|
||||
api={api}
|
||||
>
|
||||
<Text
|
||||
onClick={() => toggleOverlay()}
|
||||
marginLeft={first? 0 : 1}
|
||||
marginRight={1}
|
||||
px={1}
|
||||
bg='washedBlue'
|
||||
color='blue'
|
||||
fontSize={showNickname ? 1 : 0}
|
||||
mono={!showNickname}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</ProfileOverlay>
|
||||
</Box>
|
||||
<ProfileOverlay ship={ship} api={api} display="inline">
|
||||
<Text
|
||||
marginLeft={first? 0 : 1}
|
||||
marginRight={1}
|
||||
px={1}
|
||||
bg='washedBlue'
|
||||
color='blue'
|
||||
fontSize={showNickname ? 1 : 0}
|
||||
mono={!showNickname}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</ProfileOverlay>
|
||||
);
|
||||
}
|
||||
|
@ -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 _ from 'lodash';
|
||||
import VisibilitySensor from 'react-visibility-sensor';
|
||||
@ -40,6 +40,7 @@ const FixedOverlay = styled(Col)`
|
||||
type ProfileOverlayProps = BoxProps & {
|
||||
ship: string;
|
||||
api: any;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ProfileOverlay = (props: ProfileOverlayProps) => {
|
||||
@ -150,6 +151,16 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
|
||||
padding={3}
|
||||
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
|
||||
alignSelf='center'
|
||||
height='72px'
|
||||
|
@ -6,9 +6,7 @@ import EmbedContainer from 'react-oembed-container';
|
||||
import useSettingsState from '~/logic/state/settings';
|
||||
import { RemoteContentPolicy } from '~/types/local-update';
|
||||
import { VirtualContextProps, withVirtual } from "~/logic/lib/virtualContext";
|
||||
import { IS_IOS } from '~/logic/lib/platform';
|
||||
import withState from '~/logic/lib/withState';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
type RemoteContentProps = VirtualContextProps & {
|
||||
url: string;
|
||||
@ -130,33 +128,41 @@ return;
|
||||
});
|
||||
}
|
||||
|
||||
wrapInLink(contents, textOnly = false) {
|
||||
wrapInLink(contents, textOnly = false, unfold = false, unfoldEmbed = null, embedContainer = null) {
|
||||
const { style } = this.props;
|
||||
return (
|
||||
<Box borderRadius="1" backgroundColor="washedGray" maxWidth="min(100%, 20rem)">
|
||||
<Row
|
||||
alignItems="center"
|
||||
maxWidth="20rem"
|
||||
gapX="1" borderRadius="1" backgroundColor="washedGray">
|
||||
gapX="1">
|
||||
{ textOnly && (<Icon ml="2" display="block" icon="ArrowExternal" />)}
|
||||
{ !textOnly && unfoldEmbed && (
|
||||
<Icon
|
||||
ml='2'
|
||||
display='block'
|
||||
onClick={unfoldEmbed}
|
||||
icon={unfold ? 'TriangleSouth' : 'TriangleEast'}/>
|
||||
)}
|
||||
<BaseAnchor
|
||||
display="flex"
|
||||
p="2"
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
href={this.props.url}
|
||||
flexShrink={0}
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
minWidth="0"
|
||||
width={textOnly ? "calc(100% - 24px)" : "fit-content"}
|
||||
style={{ color: 'inherit', textDecoration: 'none', ...style }}
|
||||
className="word-break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
href={this.props.url}
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
minWidth="0"
|
||||
width={textOnly ? "calc(100% - 24px)" : "fit-content"}
|
||||
maxWidth="min(500px, 100%)"
|
||||
style={{ color: 'inherit', textDecoration: 'none', ...style }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{contents}
|
||||
</BaseAnchor>
|
||||
</Row>
|
||||
{embedContainer}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -170,7 +176,6 @@ return;
|
||||
remoteContentPolicy,
|
||||
url,
|
||||
text,
|
||||
unfold = false,
|
||||
renderUrl = true,
|
||||
imageProps = {},
|
||||
audioProps = {},
|
||||
@ -208,64 +213,58 @@ return;
|
||||
return (
|
||||
<>
|
||||
{renderUrl
|
||||
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>)
|
||||
? this.wrapInLink(
|
||||
<TruncatedText {...textProps}>{url}</TruncatedText>,
|
||||
false,
|
||||
this.state.unfold,
|
||||
this.unfoldEmbed,
|
||||
<audio
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
controls
|
||||
className={this.state.unfold ? "db" : "dn"}
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
objectFit="contain"
|
||||
height="100%"
|
||||
width="100%"
|
||||
{...audioProps}
|
||||
{...props}
|
||||
/>)
|
||||
: null}
|
||||
<audio
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
controls
|
||||
className="db"
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
objectFit="contain"
|
||||
{...audioProps}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (isVideo && remoteContentPolicy.videoShown) {
|
||||
return (
|
||||
<>
|
||||
{renderUrl
|
||||
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>)
|
||||
? this.wrapInLink(
|
||||
<TruncatedText {...textProps}>{url}</TruncatedText>,
|
||||
false,
|
||||
this.state.unfold,
|
||||
this.unfoldEmbed,
|
||||
<video
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
controls
|
||||
className={this.state.unfold ? 'db' : 'dn pa2'}
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
objectFit="contain"
|
||||
height="100%"
|
||||
width="100%"
|
||||
{...videoProps}
|
||||
{...props}
|
||||
/>)
|
||||
: null}
|
||||
<video
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
controls
|
||||
className="db"
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
objectFit="contain"
|
||||
{...videoProps}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (isOembed && remoteContentPolicy.oembedShown) {
|
||||
if (!this.state.embed || this.state.embed?.html === '') {
|
||||
this.loadOembed();
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{renderUrl
|
||||
? this.wrapInLink(<TruncatedText {...textProps}>{(this.state.embed && this.state.embed.title)
|
||||
? this.state.embed.title
|
||||
: (text || url)}</TruncatedText>, true)
|
||||
: null}
|
||||
{this.state.embed !== 'error' && this.state.embed?.html && !unfold ? <Button
|
||||
display='inline-flex'
|
||||
border={1}
|
||||
height={3}
|
||||
ml={1}
|
||||
onClick={this.unfoldEmbed}
|
||||
flexShrink={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{this.state.unfold ? 'collapse' : 'expand'}
|
||||
</Button> : null}
|
||||
<Box
|
||||
const renderEmbed = !(this.state.embed !== 'error' && this.state.embed?.html);
|
||||
const embed = <Box
|
||||
mb='2'
|
||||
width='100%'
|
||||
flexShrink={0}
|
||||
@ -277,17 +276,33 @@ return;
|
||||
{...oembedProps}
|
||||
{...props}
|
||||
>
|
||||
{this.state.embed && this.state.embed.html && this.state.unfold
|
||||
? <EmbedContainer markup={this.state.embed.html}>
|
||||
<div className="embed-container" ref={(el) => {
|
||||
this.onLoad();
|
||||
this.containerRef = el;
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
|
||||
></div>
|
||||
</EmbedContainer>
|
||||
: null}
|
||||
</Box>
|
||||
<TruncatedText
|
||||
display={(renderUrl && this.state.embed?.title && this.state.embed.title !== url) ? 'inline-block' : 'none'}
|
||||
fontWeight='bold' width='100%'>
|
||||
{this.state.embed?.title}
|
||||
</TruncatedText>
|
||||
{this.state.embed && this.state.embed.html && this.state.unfold
|
||||
? <EmbedContainer markup={this.state.embed.html}>
|
||||
<div className="embed-container" ref={(el) => {
|
||||
this.onLoad();
|
||||
this.containerRef = el;
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
|
||||
></div>
|
||||
</EmbedContainer>
|
||||
: null}
|
||||
</Box>;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{renderUrl
|
||||
? this.wrapInLink(
|
||||
<TruncatedText {...textProps}>{url}</TruncatedText>,
|
||||
renderEmbed,
|
||||
this.state.unfold,
|
||||
this.unfoldEmbed,
|
||||
embed
|
||||
) : embed}
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
|
@ -157,7 +157,7 @@ export function ShipSearch<I extends string, V extends Value<I>>(
|
||||
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 ship = `~${deSig(s)}`;
|
||||
|
@ -23,6 +23,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
|
||||
relative,
|
||||
dateNotRelative,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
...rest
|
||||
} = {
|
||||
time: true,
|
||||
@ -62,7 +63,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
|
||||
title={stamp.format(DateFormat + ' ' + TimeFormat)}
|
||||
>
|
||||
{time && (
|
||||
<Text flexShrink={0} color={color} fontSize={fontSize}>
|
||||
<Text lineHeight={lineHeight} flexShrink={0} color={color} fontSize={fontSize}>
|
||||
{timestamp}
|
||||
</Text>
|
||||
)}
|
||||
@ -70,6 +71,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
|
||||
<Text
|
||||
flexShrink={0}
|
||||
color={color}
|
||||
lineHeight={lineHeight}
|
||||
fontSize={fontSize}
|
||||
display={time ? ['none', hovering ? 'block' : 'none'] : 'block'}
|
||||
>
|
||||
|
@ -1,11 +1,16 @@
|
||||
import React, { useMemo, useRef, useCallback, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import * as ob from 'urbit-ob';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { Box, Row, Text } from '@tlon/indigo-react';
|
||||
import { Associations, Contacts, Groups, Invites } from '@urbit/api';
|
||||
|
||||
import makeIndex from '~/logic/lib/omnibox';
|
||||
import OmniboxInput from './OmniboxInput';
|
||||
import OmniboxResult from './OmniboxResult';
|
||||
@ -13,10 +18,9 @@ import { deSig } from '~/logic/lib/util';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
|
||||
import defaultApps from '~/logic/lib/default-apps';
|
||||
import {useOutsideClick} from '~/logic/lib/useOutsideClick';
|
||||
import {Portal} from '../Portal';
|
||||
import useSettingsState, {SettingsState} from '~/logic/state/settings';
|
||||
import { Tile } from '~/types';
|
||||
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
|
||||
import { Portal } from '../Portal';
|
||||
import useSettingsState, { SettingsState } from '~/logic/state/settings';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
@ -30,14 +34,21 @@ interface OmniboxProps {
|
||||
notifications: number;
|
||||
}
|
||||
|
||||
const SEARCHED_CATEGORIES = ['commands', 'ships', 'other', 'groups', 'subscriptions', 'apps'];
|
||||
const SEARCHED_CATEGORIES = [
|
||||
'commands',
|
||||
'ships',
|
||||
'other',
|
||||
'groups',
|
||||
'subscriptions',
|
||||
'apps'
|
||||
];
|
||||
const settingsSel = (s: SettingsState) => s.leap;
|
||||
|
||||
export function Omnibox(props: OmniboxProps) {
|
||||
export function Omnibox(props: OmniboxProps): ReactElement {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const leapConfig = useSettingsState(settingsSel);
|
||||
const omniboxRef = useRef<HTMLDivElement | null>(null)
|
||||
const omniboxRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@ -46,21 +57,24 @@ export function Omnibox(props: OmniboxProps) {
|
||||
const notifications = useHarkState(state => state.notifications);
|
||||
const invites = useInviteState(state => state.invites);
|
||||
const tiles = useLaunchState(state => state.tiles);
|
||||
const [leapCursor, setLeapCursor] = useState('pointer');
|
||||
|
||||
const contacts = useMemo(() => {
|
||||
const maybeShip = `~${deSig(query)}`;
|
||||
return ob.isValidPatp(maybeShip)
|
||||
? { ...contactState, [maybeShip]: {} }
|
||||
: contactState;
|
||||
const selflessContactState = omit(contactState, `~${window.ship}`);
|
||||
return ob.isValidPatp(maybeShip) && maybeShip !== `~${window.ship}`
|
||||
? { ...selflessContactState, [maybeShip]: {} }
|
||||
: selflessContactState;
|
||||
}, [contactState, query]);
|
||||
|
||||
const groups = useGroupState(state => state.groups);
|
||||
const associations = useMetadataState(state => state.associations);
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() => location.pathname.startsWith('/~landscape/ship/')
|
||||
? '/' + location.pathname.split('/').slice(2,5).join('/')
|
||||
: null,
|
||||
const selectedGroup = useMemo(
|
||||
() =>
|
||||
location.pathname.startsWith('/~landscape/ship/')
|
||||
? '/' + location.pathname.split('/').slice(2, 5).join('/')
|
||||
: null,
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
@ -71,16 +85,9 @@ export function Omnibox(props: OmniboxProps) {
|
||||
tiles,
|
||||
selectedGroup,
|
||||
groups,
|
||||
leapConfig,
|
||||
leapConfig
|
||||
);
|
||||
}, [
|
||||
selectedGroup,
|
||||
leapConfig,
|
||||
contacts,
|
||||
associations,
|
||||
groups,
|
||||
tiles
|
||||
]);
|
||||
}, [selectedGroup, leapConfig, contacts, associations, groups, tiles]);
|
||||
|
||||
const onOutsideClick = useCallback(() => {
|
||||
props.show && props.toggle();
|
||||
@ -90,7 +97,7 @@ export function Omnibox(props: OmniboxProps) {
|
||||
|
||||
// handle omnibox show
|
||||
useEffect(() => {
|
||||
if(!props.show) {
|
||||
if (!props.show) {
|
||||
return;
|
||||
}
|
||||
Mousetrap.bind('escape', props.toggle);
|
||||
@ -104,29 +111,37 @@ export function Omnibox(props: OmniboxProps) {
|
||||
}, [props.show]);
|
||||
|
||||
const initialResults = useMemo(() => {
|
||||
return new Map(SEARCHED_CATEGORIES.map((category) => {
|
||||
if (category === 'other') {
|
||||
return ['other', index.get('other').filter(({ app }) => app !== 'tutorial')];
|
||||
}
|
||||
return [category, []];
|
||||
}));
|
||||
return new Map(
|
||||
SEARCHED_CATEGORIES.map((category) => {
|
||||
if (category === 'other') {
|
||||
return [
|
||||
'other',
|
||||
index.get('other').filter(({ app }) => app !== 'tutorial')
|
||||
];
|
||||
}
|
||||
return [category, []];
|
||||
})
|
||||
);
|
||||
}, [index]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if(query.length <= 1) {
|
||||
if (query.length <= 1) {
|
||||
return initialResults;
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
const resultsMap = new Map();
|
||||
SEARCHED_CATEGORIES.map((category) => {
|
||||
const categoryIndex = index.get(category);
|
||||
resultsMap.set(category,
|
||||
resultsMap.set(
|
||||
category,
|
||||
categoryIndex.filter((result) => {
|
||||
return (
|
||||
result.title.toLowerCase().includes(q) ||
|
||||
result.link.toLowerCase().includes(q) ||
|
||||
result.app.toLowerCase().includes(q) ||
|
||||
(result.host !== null ? result.host.toLowerCase().includes(q) : false)
|
||||
(result.host !== null
|
||||
? result.host.toLowerCase().includes(q)
|
||||
: false)
|
||||
);
|
||||
})
|
||||
);
|
||||
@ -134,21 +149,26 @@ export function Omnibox(props: OmniboxProps) {
|
||||
return resultsMap;
|
||||
}, [query, index]);
|
||||
|
||||
const navigate = useCallback((app: string, link: string) => {
|
||||
props.toggle();
|
||||
if (defaultApps.includes(app.toLowerCase())
|
||||
|| app === 'profile'
|
||||
|| app === 'messages'
|
||||
|| app === 'tutorial'
|
||||
|| app === 'Links'
|
||||
|| app === 'Terminal'
|
||||
|| app === 'home'
|
||||
|| app === 'inbox') {
|
||||
history.push(link);
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
}, [history, props.toggle]);
|
||||
const navigate = useCallback(
|
||||
(app: string, link: string) => {
|
||||
props.toggle();
|
||||
if (
|
||||
defaultApps.includes(app.toLowerCase()) ||
|
||||
app === 'profile' ||
|
||||
app === 'messages' ||
|
||||
app === 'tutorial' ||
|
||||
app === 'Links' ||
|
||||
app === 'Terminal' ||
|
||||
app === 'home' ||
|
||||
app === 'inbox'
|
||||
) {
|
||||
history.push(link);
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
},
|
||||
[history, props.toggle]
|
||||
);
|
||||
|
||||
const setPreviousSelected = useCallback(() => {
|
||||
const flattenedResults = Array.from(results.values()).flat();
|
||||
@ -193,50 +213,59 @@ export function Omnibox(props: OmniboxProps) {
|
||||
}
|
||||
}, [selected, results]);
|
||||
|
||||
const control = useCallback((evt) => {
|
||||
if (evt.key === 'Escape') {
|
||||
if (query.length > 0) {
|
||||
setQuery('');
|
||||
return;
|
||||
} else if (props.show) {
|
||||
props.toggle();
|
||||
return;
|
||||
const setSelection = (app, link) => {
|
||||
setLeapCursor('pointer');
|
||||
setSelected([app, link]);
|
||||
};
|
||||
|
||||
const control = useCallback(
|
||||
(evt) => {
|
||||
if (evt.key === 'Escape') {
|
||||
if (query.length > 0) {
|
||||
setQuery('');
|
||||
return;
|
||||
} else if (props.show) {
|
||||
props.toggle();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
evt.key === 'ArrowUp' ||
|
||||
(evt.shiftKey && evt.key === 'Tab')) {
|
||||
if (evt.key === 'ArrowUp' || (evt.shiftKey && evt.key === 'Tab')) {
|
||||
evt.preventDefault();
|
||||
setPreviousSelected();
|
||||
return;
|
||||
}
|
||||
if (evt.key === 'ArrowDown' || evt.key === 'Tab') {
|
||||
evt.preventDefault();
|
||||
setNextSelected();
|
||||
return;
|
||||
}
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
if (selected.length) {
|
||||
navigate(selected[0], selected[1]);
|
||||
} else if (Array.from(results.values()).flat().length === 0) {
|
||||
setPreviousSelected();
|
||||
setLeapCursor('none');
|
||||
return;
|
||||
} else {
|
||||
navigate(
|
||||
Array.from(results.values()).flat()[0].app,
|
||||
Array.from(results.values()).flat()[0].link);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
props.toggle,
|
||||
selected,
|
||||
navigate,
|
||||
query,
|
||||
props.show,
|
||||
results,
|
||||
setPreviousSelected,
|
||||
setNextSelected
|
||||
]);
|
||||
if (evt.key === 'ArrowDown' || evt.key === 'Tab') {
|
||||
evt.preventDefault();
|
||||
setNextSelected();
|
||||
setLeapCursor('none');
|
||||
return;
|
||||
}
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
if (selected.length) {
|
||||
navigate(selected[0], selected[1]);
|
||||
} else if (Array.from(results.values()).flat().length === 0) {
|
||||
return;
|
||||
} else {
|
||||
navigate(
|
||||
Array.from(results.values()).flat()[0].app,
|
||||
Array.from(results.values()).flat()[0].link
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
props.toggle,
|
||||
selected,
|
||||
navigate,
|
||||
query,
|
||||
props.show,
|
||||
results,
|
||||
setPreviousSelected,
|
||||
setNextSelected
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const flattenedResultLinks = Array.from(results.values())
|
||||
@ -252,88 +281,106 @@ export function Omnibox(props: OmniboxProps) {
|
||||
}, []);
|
||||
|
||||
// Sort Omnibox results alphabetically
|
||||
const sortResults = (a: Record<'title', string>, b: Record<'title', string>) => {
|
||||
const sortResults = (
|
||||
a: Record<'title', string>,
|
||||
b: Record<'title', string>
|
||||
) => {
|
||||
// Do not sort unless searching (preserves order of menu actions)
|
||||
if (query === '') { return 0 };
|
||||
if (a.title < b.title) { return -1 };
|
||||
if (a.title > b.title) { return 1 };
|
||||
if (query === '') {
|
||||
return 0;
|
||||
}
|
||||
if (a.title < b.title) {
|
||||
return -1;
|
||||
}
|
||||
if (a.title > b.title) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const renderResults = useCallback(() => {
|
||||
return <Box
|
||||
maxHeight={['200px', '400px']}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
borderBottomLeftRadius='2'
|
||||
borderBottomRightRadius='2'
|
||||
>
|
||||
{SEARCHED_CATEGORIES
|
||||
.map(category => Object({ category, categoryResults: results.get(category) }))
|
||||
.filter(category => category.categoryResults.length > 0)
|
||||
.map(({ category, categoryResults }, i) => {
|
||||
const categoryTitle = (category === 'other')
|
||||
? null : <Row pl='2' height='5' alignItems='center' bg='washedGray'><Text gray bold>{category.charAt(0).toUpperCase() + category.slice(1)}</Text></Row>;
|
||||
const sel = selected?.length ? selected[1] : '';
|
||||
return (<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
|
||||
{categoryTitle}
|
||||
{categoryResults
|
||||
.sort(sortResults)
|
||||
.map((result, i2) => (
|
||||
<OmniboxResult
|
||||
key={i2}
|
||||
icon={result.app}
|
||||
text={result.title}
|
||||
subtext={result.host}
|
||||
link={result.link}
|
||||
navigate={() => navigate(result.app, result.link)}
|
||||
selected={sel}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Box>;
|
||||
return (
|
||||
<Box
|
||||
maxHeight={['200px', '400px']}
|
||||
overflowY='auto'
|
||||
overflowX='hidden'
|
||||
borderBottomLeftRadius='2'
|
||||
borderBottomRightRadius='2'
|
||||
>
|
||||
{SEARCHED_CATEGORIES.map(category =>
|
||||
Object({ category, categoryResults: results.get(category) })
|
||||
)
|
||||
.filter(category => category.categoryResults.length > 0)
|
||||
.map(({ category, categoryResults }, i) => {
|
||||
const categoryTitle =
|
||||
category === 'other' ? null : (
|
||||
<Row pl='2' height='5' alignItems='center' bg='washedGray'>
|
||||
<Text gray bold>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</Text>
|
||||
</Row>
|
||||
);
|
||||
const sel = selected?.length ? selected[1] : '';
|
||||
return (
|
||||
<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
|
||||
{categoryTitle}
|
||||
{categoryResults.sort(sortResults).map((result, i2) => (
|
||||
<OmniboxResult
|
||||
key={i2}
|
||||
icon={result.app}
|
||||
text={result.title}
|
||||
subtext={result.host}
|
||||
link={result.link}
|
||||
cursor={leapCursor}
|
||||
navigate={() => navigate(result.app, result.link)}
|
||||
setSelection={() => setSelection(result.app, result.link)}
|
||||
selected={sel}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}, [results, navigate, selected, contactState, notifications, invites]);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Box
|
||||
backgroundColor='scales.black30'
|
||||
width='100%'
|
||||
height='100%'
|
||||
position='absolute'
|
||||
top='0'
|
||||
right='0'
|
||||
zIndex={11}
|
||||
display={props.show ? 'block' : 'none'}
|
||||
>
|
||||
<Row justifyContent='center'>
|
||||
<Box
|
||||
mt={['10vh', '20vh']}
|
||||
width='max(50vw, 300px)'
|
||||
maxWidth='600px'
|
||||
borderRadius='2'
|
||||
backgroundColor='white'
|
||||
<Box
|
||||
backgroundColor='scales.black30'
|
||||
width='100%'
|
||||
height='100%'
|
||||
position='absolute'
|
||||
top='0'
|
||||
right='0'
|
||||
zIndex={11}
|
||||
display={props.show ? 'block' : 'none'}
|
||||
>
|
||||
<Row justifyContent='center'>
|
||||
<Box
|
||||
mt={['10vh', '20vh']}
|
||||
width='max(50vw, 300px)'
|
||||
maxWidth='600px'
|
||||
borderRadius='2'
|
||||
backgroundColor='white'
|
||||
ref={(el) => {
|
||||
omniboxRef.current = el;
|
||||
}}
|
||||
>
|
||||
<OmniboxInput
|
||||
ref={(el) => {
|
||||
omniboxRef.current = el;
|
||||
}}
|
||||
>
|
||||
<OmniboxInput
|
||||
ref={(el) => {
|
||||
inputRef.current = el;
|
||||
}}
|
||||
control={e => control(e)}
|
||||
search={search}
|
||||
query={query}
|
||||
/>
|
||||
{renderResults()}
|
||||
</Box>
|
||||
</Row>
|
||||
</Box>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
inputRef.current = el;
|
||||
}}
|
||||
control={e => control(e)}
|
||||
search={search}
|
||||
query={query}
|
||||
/>
|
||||
{renderResults()}
|
||||
</Box>
|
||||
</Row>
|
||||
</Box>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export default withLocalState(Omnibox, ['toggleOmnibox', 'omniboxShown']);
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { BaseInput } from '@tlon/indigo-react';
|
||||
|
||||
@ -6,33 +5,31 @@ export class OmniboxInput extends Component {
|
||||
render() {
|
||||
const { props } = this;
|
||||
return (
|
||||
<BaseInput
|
||||
ref={(el) => {
|
||||
this.input = el;
|
||||
<BaseInput
|
||||
ref={(el) => {
|
||||
this.input = el;
|
||||
if (el && document.activeElement.isSameNode(el)) {
|
||||
el.blur();
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
width='100%'
|
||||
p='2'
|
||||
backgroundColor='white'
|
||||
color='black'
|
||||
border='1px solid transparent'
|
||||
borderRadius='2'
|
||||
maxWidth='calc(600px - 1.15rem)'
|
||||
fontSize='1'
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
placeholder='Search...'
|
||||
onKeyDown={props.control}
|
||||
onChange={props.search}
|
||||
spellCheck={false}
|
||||
value={props.query}
|
||||
/>
|
||||
}}
|
||||
width='100%'
|
||||
p='2'
|
||||
backgroundColor='white'
|
||||
color='black'
|
||||
border='1px solid transparent'
|
||||
borderRadius='2'
|
||||
maxWidth='calc(600px - 1.15rem)'
|
||||
fontSize='1'
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
placeholder='Search...'
|
||||
onKeyDown={props.control}
|
||||
onChange={props.search}
|
||||
spellCheck={false}
|
||||
value={props.query}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OmniboxInput;
|
||||
|
||||
|
@ -21,52 +21,143 @@ export class OmniboxResult extends Component {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (prevProps &&
|
||||
if (
|
||||
prevProps &&
|
||||
!state.hovered &&
|
||||
prevProps.selected !== props.selected &&
|
||||
props.selected === props.link
|
||||
) {
|
||||
this.result.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
) {
|
||||
this.result.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
getIcon(icon, selected, link, invites, notifications, text, color) {
|
||||
const iconFill = (this.state.hovered || (selected === link)) ? 'white' : 'black';
|
||||
const bulletFill = (this.state.hovered || (selected === link)) ? 'white' : 'blue';
|
||||
const iconFill =
|
||||
this.state.hovered || selected === link ? 'white' : 'black';
|
||||
const bulletFill =
|
||||
this.state.hovered || selected === link ? 'white' : 'blue';
|
||||
|
||||
const inviteCount = [].concat(...Object.values(invites).map(obj => Object.values(obj)));
|
||||
const inviteCount = [].concat(
|
||||
...Object.values(invites).map((obj) => Object.values(obj))
|
||||
);
|
||||
|
||||
let graphic = <div />;
|
||||
if (defaultApps.includes(icon.toLowerCase())
|
||||
|| icon.toLowerCase() === 'links'
|
||||
|| icon.toLowerCase() === 'terminal')
|
||||
{
|
||||
icon = (icon === 'Link') ? 'Collection' :
|
||||
(icon === 'Terminal') ? 'Dojo' : icon;
|
||||
graphic = <Icon display="inline-block" verticalAlign="middle" icon={icon} mr='2' size='18px' color={iconFill} />;
|
||||
if (
|
||||
defaultApps.includes(icon.toLowerCase()) ||
|
||||
icon.toLowerCase() === 'links' ||
|
||||
icon.toLowerCase() === 'terminal'
|
||||
) {
|
||||
icon =
|
||||
icon === 'Link' ? 'Collection' : icon === 'Terminal' ? 'Dojo' : icon;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon={icon}
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'inbox') {
|
||||
graphic = <Box display='flex' verticalAlign='middle' position="relative">
|
||||
<Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />
|
||||
{(notifications > 0 || inviteCount.length > 0) && (
|
||||
<Icon display='inline-block' icon='Bullet' style={{ position: 'absolute', top: -5, left: 5 }} color={bulletFill} />
|
||||
)}
|
||||
</Box>;
|
||||
graphic = (
|
||||
<Box display='flex' verticalAlign='middle' position='relative'>
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Inbox'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
{(notifications > 0 || inviteCount.length > 0) && (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
icon='Bullet'
|
||||
style={{ position: 'absolute', top: -5, left: 5 }}
|
||||
color={bulletFill}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else if (icon === 'logout') {
|
||||
graphic = <Icon display="inline-block" verticalAlign="middle" icon='SignOut' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='SignOut'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'profile') {
|
||||
text = text.startsWith('Profile') ? window.ship : text;
|
||||
graphic = <Sigil color={color} classes='dib flex-shrink-0 v-mid mr2' ship={text} size={18} icon padding={2} />;
|
||||
graphic = (
|
||||
<Sigil
|
||||
color={color}
|
||||
classes='dib flex-shrink-0 v-mid mr2'
|
||||
ship={text}
|
||||
size={18}
|
||||
icon
|
||||
padding={2}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'home') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Home' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Home'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'notifications') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Inbox'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'messages') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Users' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Users'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'tutorial') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Tutorial' mr='2' size='18px' color={iconFill} />;
|
||||
}
|
||||
else {
|
||||
graphic = <Icon display='inline-block' icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Tutorial'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
icon='NullIcon'
|
||||
verticalAlign='middle'
|
||||
mr='2'
|
||||
size='16px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return graphic;
|
||||
@ -77,53 +168,81 @@ export class OmniboxResult extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { icon, text, subtext, link, navigate, selected, invites, notificationsCount, contacts } = this.props;
|
||||
const {
|
||||
icon,
|
||||
text,
|
||||
subtext,
|
||||
link,
|
||||
cursor,
|
||||
navigate,
|
||||
selected,
|
||||
invites,
|
||||
notificationsCount,
|
||||
contacts,
|
||||
setSelection
|
||||
} = this.props;
|
||||
|
||||
const color = contacts?.[text] ? `#${uxToHex(contacts[text].color)}` : "#000000";
|
||||
const graphic = this.getIcon(icon, selected, link, invites, notificationsCount, text, color);
|
||||
const color = contacts?.[text]
|
||||
? `#${uxToHex(contacts[text].color)}`
|
||||
: '#000000';
|
||||
const graphic = this.getIcon(
|
||||
icon,
|
||||
selected,
|
||||
link,
|
||||
invites,
|
||||
notificationsCount,
|
||||
text,
|
||||
color
|
||||
);
|
||||
|
||||
return (
|
||||
<Row
|
||||
py='2'
|
||||
px='2'
|
||||
cursor='pointer'
|
||||
onMouseEnter={() => this.setHover(true)}
|
||||
onMouseLeave={() => this.setHover(false)}
|
||||
backgroundColor={
|
||||
this.state.hovered || selected === link ? 'blue' : 'white'
|
||||
}
|
||||
onClick={navigate}
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
ref={this.result}
|
||||
py='2'
|
||||
px='2'
|
||||
cursor={cursor}
|
||||
onMouseMove={() => setSelection()}
|
||||
onMouseLeave={() => this.setHover(false)}
|
||||
backgroundColor={
|
||||
this.state.hovered || selected === link ? 'blue' : 'white'
|
||||
}
|
||||
onClick={navigate}
|
||||
width='100%'
|
||||
justifyContent='space-between'
|
||||
ref={this.result}
|
||||
>
|
||||
<Box display="flex" verticalAlign="middle" maxWidth="60%" flexShrink={0}>
|
||||
{graphic}
|
||||
<Text
|
||||
mono={(icon == 'profile' && text.startsWith('~'))}
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
width='100%'
|
||||
overflow='hidden'
|
||||
textOverflow='ellipsis'
|
||||
whiteSpace='pre'
|
||||
mr='1'
|
||||
<Box
|
||||
display='flex'
|
||||
verticalAlign='middle'
|
||||
maxWidth='60%'
|
||||
flexShrink={0}
|
||||
>
|
||||
{text.startsWith("~") ? cite(text) : text}
|
||||
</Text>
|
||||
{graphic}
|
||||
<Text
|
||||
mono={icon == 'profile' && text.startsWith('~')}
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
width='100%'
|
||||
overflow='hidden'
|
||||
textOverflow='ellipsis'
|
||||
whiteSpace='pre'
|
||||
mr='1'
|
||||
>
|
||||
{text.startsWith('~') ? cite(text) : text}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text pr='2'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
width='100%'
|
||||
minWidth={0}
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="pre"
|
||||
overflow="hidden"
|
||||
maxWidth="40%"
|
||||
textAlign='right'
|
||||
<Text
|
||||
pr='2'
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
width='100%'
|
||||
minWidth={0}
|
||||
textOverflow='ellipsis'
|
||||
whiteSpace='pre'
|
||||
overflow='hidden'
|
||||
maxWidth='40%'
|
||||
textAlign='right'
|
||||
>
|
||||
{subtext}
|
||||
</Text>
|
||||
@ -136,4 +255,4 @@ export default withState(OmniboxResult, [
|
||||
[useInviteState],
|
||||
[useHarkState, ['notificationsCount']],
|
||||
[useContactState]
|
||||
]);
|
||||
]);
|
||||
|
@ -2,6 +2,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/~landscape/fonts/inter-regular.woff2") format("woff2"),
|
||||
url("https://media.urbit.org/fonts/Inter-Regular.woff2") format("woff2");
|
||||
}
|
||||
@ -10,6 +11,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("https://media.urbit.org/fonts/Inter-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@ -17,6 +19,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("https://media.urbit.org/fonts/Inter-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@ -24,6 +27,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/~landscape/fonts/inter-italic.woff2") format("woff2"),
|
||||
url("https://media.urbit.org/fonts/Inter-Italic.woff2") format("woff2");
|
||||
}
|
||||
@ -32,6 +36,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/~landscape/fonts/inter-bold.woff2") format("woff2"),
|
||||
url("https://media.urbit.org/fonts/Inter-Bold.woff2") format("woff2");
|
||||
}
|
||||
@ -39,6 +44,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/~landscape/fonts/inter-bolditalic.woff2") format("woff2"),
|
||||
url("https://media.urbit.org/fonts/Inter-BoldItalic.woff2") format("woff2");
|
||||
}
|
||||
@ -48,6 +54,7 @@
|
||||
src: url("/~landscape/fonts/sourcecodepro-extralight.woff2"),
|
||||
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-extralight.woff");
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@ -55,6 +62,7 @@
|
||||
src: url("/~landscape/fonts/sourcecodepro-light.woff2"),
|
||||
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-light.woff");
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@ -62,6 +70,7 @@
|
||||
src: url("/~landscape/fonts/sourcecodepro-regular.woff2"),
|
||||
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-regular.woff");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@ -69,6 +78,7 @@
|
||||
src: url("(/~landscape/fonts/sourcecodepro-medium.woff2"),
|
||||
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-medium.woff");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@ -76,6 +86,7 @@
|
||||
src: url("/~landscape/fonts/sourcecodepro-semibold.woff2"),
|
||||
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-semibold.woff");
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@ -83,5 +94,6 @@
|
||||
src: url("/~landscape/fonts/sourcecodepro-bold.woff2"),
|
||||
url("https://storage.googleapis.com/media.urbit.org/fonts/scp-bold.woff");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ import GlobalApi from '~/logic/api/global';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
import { FormSubmit } from '~/views/components/FormSubmit';
|
||||
import { ChannelWritePerms } from '../ChannelWritePerms';
|
||||
import {FormGroupChild} from '~/views/components/FormGroup';
|
||||
|
||||
function PermissionsSummary(props: {
|
||||
writersSize: number;
|
||||
@ -158,7 +159,8 @@ export function GraphPermissions(props: GraphPermissionsProps) {
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: 'contents' }}>
|
||||
<Col mt="4" flexShrink={0} gapY="5">
|
||||
<FormGroupChild id="permissions" />
|
||||
<Col mx="4" mt="4" flexShrink={0} gapY="5">
|
||||
<Col gapY="1" mt="0">
|
||||
<Text id="permissions" fontWeight="bold" fontSize="2">
|
||||
Permissions
|
||||
@ -187,7 +189,6 @@ export function GraphPermissions(props: GraphPermissionsProps) {
|
||||
caption="If enabled, all members of the group can comment on this channel"
|
||||
/>
|
||||
)}
|
||||
<FormSubmit>Update Permissions</FormSubmit>
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
|
@ -1,19 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Formik, Form } from 'formik';
|
||||
import React from "react";
|
||||
import { Formik, Form } from "formik";
|
||||
|
||||
import {
|
||||
ManagedTextInputField as Input,
|
||||
Col,
|
||||
Label,
|
||||
Text
|
||||
} from '@tlon/indigo-react';
|
||||
import { Association } from '@urbit/api';
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Association } from "@urbit/api";
|
||||
|
||||
import { FormError } from '~/views/components/FormError';
|
||||
import { ColorInput } from '~/views/components/ColorInput';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { FormSubmit } from '~/views/components/FormSubmit';
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { ColorInput } from "~/views/components/ColorInput";
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { FormSubmit } from "~/views/components/FormSubmit";
|
||||
import { FormGroupChild } from "~/views/components/FormGroup";
|
||||
|
||||
interface FormSchema {
|
||||
title: string;
|
||||
@ -30,9 +31,9 @@ export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
const { association, api } = props;
|
||||
const { metadata } = association;
|
||||
const initialValues: FormSchema = {
|
||||
title: metadata?.title || '',
|
||||
description: metadata?.description || '',
|
||||
color: metadata?.color || '0x0'
|
||||
title: metadata?.title || "",
|
||||
description: metadata?.description || "",
|
||||
color: metadata?.color || "0x0",
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
@ -44,8 +45,9 @@ export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form style={{ display: 'contents' }}>
|
||||
<Col mb="4" flexShrink={0} gapY="4">
|
||||
<Form style={{ display: "contents" }}>
|
||||
<FormGroupChild id="details" />
|
||||
<Col mx="4" mb="4" flexShrink={0} gapY="4">
|
||||
<Col mb={3}>
|
||||
<Text id="details" fontSize="2" fontWeight="bold">
|
||||
Channel Details
|
||||
@ -69,9 +71,6 @@ export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
label="Color"
|
||||
caption="Change the color of this channel"
|
||||
/>
|
||||
<FormSubmit>
|
||||
Update Details
|
||||
</FormSubmit>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Col>
|
||||
</Form>
|
||||
|
@ -28,7 +28,7 @@ export function ChannelNotifications(props: ChannelNotificationsProps) {
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<Col mb="6" gapY="4" flexShrink={0}>
|
||||
<Col mx="4" mb="6" gapY="4" flexShrink={0}>
|
||||
<Text ref={anchorRef} id="notifications" fontSize="2" fontWeight="bold">
|
||||
Channel Notifications
|
||||
</Text>
|
||||
|
@ -13,7 +13,7 @@ export function ChannelPopoverRoutesSidebar(props: {
|
||||
|
||||
return (
|
||||
<Col
|
||||
display={['none', 'flex-column']}
|
||||
display={['none', 'flex']}
|
||||
minWidth="200px"
|
||||
borderRight="1"
|
||||
borderRightColor="washedGray"
|
||||
|
@ -19,6 +19,7 @@ import { useHistory, Link } from 'react-router-dom';
|
||||
import { ChannelNotifications } from './Notifications';
|
||||
import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton';
|
||||
import { isChannelAdmin, isHost } from '~/logic/lib/group';
|
||||
import {FormGroup} from '~/views/components/FormGroup';
|
||||
|
||||
interface ChannelPopoverRoutesProps {
|
||||
baseUrl: string;
|
||||
@ -83,10 +84,10 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
isOwner={isOwner}
|
||||
baseUrl={props.baseUrl}
|
||||
/>
|
||||
<Col height="100%" overflowY="auto" p="5" flexGrow={1}>
|
||||
<FormGroup onReset={onDismiss} height="100%" overflowY="auto" pt="5" flexGrow={1}>
|
||||
<ChannelNotifications {...props} />
|
||||
{!isOwner && (
|
||||
<Col mb="6" flexShrink={0}>
|
||||
<Col mx="4" mb="6" flexShrink={0}>
|
||||
<Text id="unsubscribe" fontSize="2" fontWeight="bold">
|
||||
Unsubscribe from Channel
|
||||
</Text>
|
||||
@ -107,7 +108,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
<ChannelDetails {...props} />
|
||||
<GraphPermissions {...props} />
|
||||
{ isOwner ? (
|
||||
<Col mt="5" mb="6" flexShrink={0}>
|
||||
<Col mx="4" mt="5" mb="6" flexShrink={0}>
|
||||
<Text id="archive" fontSize="2" fontWeight="bold">
|
||||
Archive channel
|
||||
</Text>
|
||||
@ -124,7 +125,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
</Col>
|
||||
|
||||
) : (
|
||||
<Col mt="5" mb="6" flexShrink={0}>
|
||||
<Col mx="4" my="6" flexShrink={0}>
|
||||
<Text id="remove" fontSize="2" fontWeight="bold">
|
||||
Remove channel from group
|
||||
</Text>
|
||||
@ -143,7 +144,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</FormGroup>
|
||||
</Row>
|
||||
</ModalOverlay>
|
||||
);
|
||||
|
@ -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);
|
@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { Text } from '@tlon/indigo-react';
|
||||
import { Text, Anchor } from '@tlon/indigo-react';
|
||||
import { GroupLink } from '~/views/components/GroupLink';
|
||||
import { Row } from '@tlon/indigo-react';
|
||||
|
||||
@ -22,7 +22,6 @@ const DISABLED_INLINE_TOKENS = [
|
||||
'autoLink',
|
||||
'url',
|
||||
'email',
|
||||
'link',
|
||||
'reference'
|
||||
];
|
||||
|
||||
@ -75,6 +74,9 @@ const renderers = {
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
link: (props) => {
|
||||
return <Anchor src={props.href} borderBottom="1" color="black">{props.children}</Anchor>
|
||||
}
|
||||
};
|
||||
|
@ -23,7 +23,7 @@ export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): R
|
||||
anchorRef
|
||||
);
|
||||
return (
|
||||
<Col {...rest} ref={anchorRef} gapY="4">
|
||||
<Col {...rest} ref={anchorRef} gapY="4" maxWidth={['100%', '288px']}>
|
||||
<Row gapX="2" width="100%">
|
||||
<MetadataIcon
|
||||
width="40px"
|
||||
|
@ -54,6 +54,7 @@ function GroupFeed(props) {
|
||||
return;
|
||||
}
|
||||
api.graph.getNewest(graphResource.ship, graphResource.name, 100);
|
||||
api.hark.markCountAsRead(association, '/', 'post');
|
||||
}, [graphPath]);
|
||||
|
||||
if (!graphPath) {
|
||||
|
@ -1,30 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Col } from '@tlon/indigo-react';
|
||||
import { MentionText } from '~/views/components/MentionText';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import { Col, Box } from '@tlon/indigo-react';
|
||||
import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
|
||||
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) {
|
||||
const { post, isParent, api, isReply } = props;
|
||||
const contacts = useContactState(state => state.contacts);
|
||||
|
||||
return (
|
||||
<Col
|
||||
<TruncatedBox
|
||||
display="-webkit-box"
|
||||
width="100%"
|
||||
pl="2"
|
||||
pr="2"
|
||||
pb={isParent || isReply ? "0" : "2"}
|
||||
maxHeight={ isParent ? "none" : "300px" }
|
||||
px="2"
|
||||
pb="2"
|
||||
truncate={isParent ? null : 8}
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
display="inline-block">
|
||||
<MentionText
|
||||
contacts={contacts}
|
||||
content={post.contents}
|
||||
api={api}
|
||||
>
|
||||
<GraphContentWide
|
||||
transcluded={0}
|
||||
post={post}
|
||||
api={api}
|
||||
showOurContact
|
||||
/>
|
||||
</Col>
|
||||
</TruncatedBox>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -79,6 +79,7 @@ export default function PostReplies(props) {
|
||||
baseUrl={baseUrl}
|
||||
history={history}
|
||||
isParent={true}
|
||||
parentPost={parentNode?.post}
|
||||
vip={vip}
|
||||
group={group}
|
||||
/>
|
||||
|
@ -81,6 +81,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
|
||||
if(group === TUTORIAL_GROUP_RESOURCE) {
|
||||
await api.settings.putEntry('tutorial', 'joined', Date.now());
|
||||
}
|
||||
if (group in groups) {
|
||||
return history.push(`/~landscape${group}`);
|
||||
}
|
||||
await api.groups.join(ship, name);
|
||||
try {
|
||||
await waiter((p) => {
|
||||
@ -111,6 +114,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
|
||||
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
|
||||
const [ship, name] = values.group.split('/');
|
||||
const path = `/ship/${ship}/${name}`;
|
||||
if (path in groups) {
|
||||
return history.push(`/~landscape${path}`);
|
||||
}
|
||||
// skip if it's unmanaged
|
||||
try {
|
||||
const prev = await api.metadata.preview(path);
|
||||
@ -151,47 +157,50 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
|
||||
</StatelessAsyncButton>
|
||||
</Col>
|
||||
) : preview ? (
|
||||
<GroupSummary
|
||||
metadata={preview.metadata}
|
||||
memberCount={preview?.members}
|
||||
channelCount={preview?.['channel-count']}
|
||||
>
|
||||
{ Object.keys(preview.channels).length > 0 && (
|
||||
<Col
|
||||
gapY="2"
|
||||
p="2"
|
||||
borderRadius="2"
|
||||
border="1"
|
||||
borderColor="washedGray"
|
||||
bg="washedBlue"
|
||||
maxHeight="300px"
|
||||
overflowY="auto"
|
||||
>
|
||||
<Text gray fontSize="1">
|
||||
Channels
|
||||
</Text>
|
||||
<Box width="100%" flexShrink="0">
|
||||
{Object.values(preview.channels).map(({ metadata }: any) => (
|
||||
<Row width="100%">
|
||||
<Icon
|
||||
mr="2"
|
||||
color="blue"
|
||||
icon={getModuleIcon(metadata?.config?.graph) as any}
|
||||
/>
|
||||
<Text color="blue">{metadata.title} </Text>
|
||||
</Row>
|
||||
))}
|
||||
</Box>
|
||||
</Col>
|
||||
)}
|
||||
<>
|
||||
<GroupSummary
|
||||
metadata={preview.metadata}
|
||||
memberCount={preview?.members}
|
||||
channelCount={preview?.['channel-count']}
|
||||
>
|
||||
{ Object.keys(preview.channels).length > 0 && (
|
||||
<Col
|
||||
gapY="2"
|
||||
p="2"
|
||||
borderRadius="2"
|
||||
border="1"
|
||||
borderColor="washedGray"
|
||||
bg="washedBlue"
|
||||
maxHeight="300px"
|
||||
overflowY="auto"
|
||||
>
|
||||
<Text gray fontSize="1">
|
||||
Channels
|
||||
</Text>
|
||||
<Box width="100%" flexShrink="0">
|
||||
{Object.values(preview.channels).map(({ metadata }: any) => (
|
||||
<Row width="100%">
|
||||
<Icon
|
||||
mr="2"
|
||||
color="blue"
|
||||
icon={getModuleIcon(metadata?.config?.graph) as any}
|
||||
/>
|
||||
<Text color="blue">{metadata.title} </Text>
|
||||
</Row>
|
||||
))}
|
||||
</Box>
|
||||
</Col>
|
||||
)}
|
||||
</GroupSummary>
|
||||
<StatelessAsyncButton
|
||||
marginTop={3}
|
||||
primary
|
||||
name="join"
|
||||
onClick={() => onConfirm(preview.group)}
|
||||
>
|
||||
Join {preview.metadata.title}
|
||||
</StatelessAsyncButton>
|
||||
</GroupSummary>
|
||||
</>
|
||||
) : (
|
||||
<Col width="100%" gapY="4">
|
||||
<Formik
|
||||
|
@ -295,7 +295,7 @@ function Participant(props: {
|
||||
const resource = resourceFromPath(association.group);
|
||||
if(contact.pending) {
|
||||
await api.groups.changePolicy(
|
||||
resource,
|
||||
resource,
|
||||
{ invite: { removeInvites: [`~${contact.patp}`] } }
|
||||
);
|
||||
} else {
|
||||
@ -305,12 +305,12 @@ function Participant(props: {
|
||||
|
||||
const avatar =
|
||||
contact?.avatar !== null && !hideAvatars ? (
|
||||
<Image
|
||||
src={contact.avatar}
|
||||
height={32}
|
||||
width={32}
|
||||
<Image
|
||||
src={contact.avatar}
|
||||
height={32}
|
||||
width={32}
|
||||
display='inline-block'
|
||||
style={{ objectFit: 'cover' }}
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Sigil ship={contact.patp} size={32} color={`#${color}`} />
|
||||
@ -386,9 +386,9 @@ function Participant(props: {
|
||||
{(contact.patp !== window.ship) && (<StatelessAsyncAction onClick={onKick} bg="transparent">
|
||||
<Text color="red">Kick from {title}</Text>
|
||||
</StatelessAsyncAction>)}
|
||||
<StatelessAsyncAction onClick={onPromote} bg="transparent">
|
||||
{!contact.pending && <StatelessAsyncAction onClick={onPromote} bg="transparent">
|
||||
Promote to Admin
|
||||
</StatelessAsyncAction>
|
||||
</StatelessAsyncAction>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -18,7 +18,11 @@ export function useGraphModule(
|
||||
}
|
||||
|
||||
const notifications = graphUnreads?.[s]?.['/']?.notifications;
|
||||
if ( notifications > 0 ) {
|
||||
if (
|
||||
notifications &&
|
||||
((typeof notifications === 'number' && notifications > 0)
|
||||
|| notifications.length)
|
||||
) {
|
||||
return 'notification';
|
||||
}
|
||||
|
||||
|
@ -136,7 +136,7 @@ export function SidebarItem(props: {
|
||||
{DM ? img : (
|
||||
<Icon
|
||||
display="block"
|
||||
color={isSynced ? 'black' : 'gray'}
|
||||
color={isSynced ? 'black' : 'lightGray'}
|
||||
icon={getModuleIcon(mod) as any}
|
||||
/>
|
||||
)
|
||||
|
@ -24,6 +24,7 @@ import { Workspace } from '~/types/workspace';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import {IS_SAFARI} from '~/logic/lib/platform';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
|
||||
export function SidebarListHeader(props: {
|
||||
api: GlobalApi;
|
||||
@ -54,15 +55,15 @@ export function SidebarListHeader(props: {
|
||||
|
||||
const noun = (props.workspace?.type === 'messages') ? 'Messages' : 'Channels';
|
||||
|
||||
const isFeedEnabled =
|
||||
metadata &&
|
||||
metadata.config &&
|
||||
metadata.config.group &&
|
||||
'resource' in metadata.config.group;
|
||||
const feedPath = metadata?.config?.group?.resource;
|
||||
|
||||
const unreadCount = useHarkState(
|
||||
s => s.unreads?.graph?.[feedPath ?? ""]?.["/"]?.unreads as number ?? 0
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{( isFeedEnabled ) ? (
|
||||
{( !!feedPath) ? (
|
||||
<Row
|
||||
flexShrink="0"
|
||||
alignItems="center"
|
||||
@ -90,6 +91,9 @@ export function SidebarListHeader(props: {
|
||||
<Text>
|
||||
Group Feed
|
||||
</Text>
|
||||
<Text mr="1" color="blue">
|
||||
{ unreadCount > 0 && unreadCount}
|
||||
</Text>
|
||||
</Row>
|
||||
) : null
|
||||
}
|
||||
|
1
pkg/npm/.gitignore
vendored
1
pkg/npm/.gitignore
vendored
@ -1 +0,0 @@
|
||||
package-lock.json
|
46
pkg/npm/api/package-lock.json
generated
Normal file
46
pkg/npm/api/package-lock.json
generated
Normal 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
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
6067
pkg/npm/http-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -65,3 +65,13 @@
|
||||
*/
|
||||
u3_noun
|
||||
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);
|
||||
|
@ -5,49 +5,16 @@
|
||||
|
||||
#include <ctype.h>
|
||||
|
||||
/* functions
|
||||
*/
|
||||
u3_noun
|
||||
_parse_ud(u3_noun txt) {
|
||||
c3_c* c = u3a_string(txt);
|
||||
static inline u3_noun
|
||||
_parse_ud(u3_noun a)
|
||||
{
|
||||
u3_weak pro;
|
||||
|
||||
// First character must represent a digit
|
||||
c3_c* cur = c;
|
||||
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;
|
||||
}
|
||||
if ( u3_none == (pro = u3s_sift_ud(u3x_atom(a))) ) {
|
||||
return u3_nul;
|
||||
}
|
||||
|
||||
u3a_free(c);
|
||||
return u3nc(0, total);
|
||||
return u3nc(u3_nul, pro);
|
||||
}
|
||||
|
||||
static
|
||||
|
@ -313,12 +313,17 @@ u3i_word(c3_w dat_w)
|
||||
u3_atom
|
||||
u3i_chub(c3_d dat_d)
|
||||
{
|
||||
c3_w dat_w[2] = {
|
||||
dat_d & 0xffffffffULL,
|
||||
dat_d >> 32
|
||||
};
|
||||
if ( c3y == u3a_is_cat(dat_d) ) {
|
||||
return (u3_atom)dat_d;
|
||||
}
|
||||
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.
|
||||
|
@ -98,28 +98,22 @@ _cm_punt(u3_noun tax)
|
||||
}
|
||||
#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.
|
||||
*/
|
||||
static void
|
||||
_cm_emergency(c3_c* cap_c, c3_l sig_l)
|
||||
{
|
||||
_write(2, "\r\n", 2);
|
||||
_write(2, cap_c, strlen(cap_c));
|
||||
c3_i ret_i;
|
||||
|
||||
ret_i = write(2, "\r\n", 2);
|
||||
ret_i = write(2, cap_c, strlen(cap_c));
|
||||
|
||||
if ( sig_l ) {
|
||||
_write(2, ": ", 2);
|
||||
_write(2, &sig_l, 4);
|
||||
ret_i = write(2, ": ", 2);
|
||||
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)
|
||||
@ -127,7 +121,7 @@ static void _cm_overflow(void *arg1, void *arg2, void *arg3)
|
||||
(void)(arg1);
|
||||
(void)(arg2);
|
||||
(void)(arg3);
|
||||
siglongjmp(u3_Signal, c3__over);
|
||||
u3m_signal(c3__over);
|
||||
}
|
||||
|
||||
/* _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);
|
||||
}
|
||||
else {
|
||||
siglongjmp(u3_Signal, sig_l);
|
||||
u3m_signal(sig_l);
|
||||
}
|
||||
}
|
||||
|
||||
@ -661,7 +655,10 @@ u3m_dump(void)
|
||||
c3_i
|
||||
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();
|
||||
}
|
||||
|
||||
@ -688,8 +685,9 @@ u3m_bail(u3_noun how)
|
||||
//
|
||||
switch ( how ) {
|
||||
case c3__foul:
|
||||
case c3__meme:
|
||||
case c3__oops: {
|
||||
// XX set exit code
|
||||
//
|
||||
fprintf(stderr, "bailing out\r\n");
|
||||
abort();
|
||||
}
|
||||
@ -700,6 +698,9 @@ u3m_bail(u3_noun how)
|
||||
// choice but to use the signal process; and we require the flat
|
||||
// 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)));
|
||||
u3m_signal(how);
|
||||
}
|
||||
|
@ -1205,16 +1205,11 @@ u3r_mp(mpz_t a_mp,
|
||||
else {
|
||||
u3a_atom* b_u = u3a_to_ptr(b);
|
||||
c3_w len_w = b_u->len_w;
|
||||
c3_d bit_d = (c3_d)len_w << 5;
|
||||
|
||||
// avoid reallocation on import, if possible
|
||||
//
|
||||
if ( (len_w >> 27) ) {
|
||||
mpz_init(a_mp);
|
||||
}
|
||||
else {
|
||||
mpz_init2(a_mp, len_w << 5);
|
||||
}
|
||||
|
||||
mpz_init2(a_mp, (c3_w)c3_min(bit_d, UINT32_MAX));
|
||||
mpz_import(a_mp, len_w, -1, sizeof(c3_w), 0, 0, b_u->buf_w);
|
||||
}
|
||||
}
|
||||
|
@ -856,3 +856,128 @@ u3s_cue_atom(u3_atom a)
|
||||
|
||||
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);
|
||||
}
|
||||
|
@ -9,6 +9,108 @@ _setup(void)
|
||||
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
|
||||
_test_en_base16(void)
|
||||
{
|
||||
@ -400,6 +502,11 @@ _test_jets(void)
|
||||
{
|
||||
c3_i ret_i = 1;
|
||||
|
||||
if ( !_test_sift_ud() ) {
|
||||
fprintf(stderr, "test jets: sift_ud: failed\r\n");
|
||||
ret_i = 0;
|
||||
}
|
||||
|
||||
if ( !_test_base16() ) {
|
||||
fprintf(stderr, "test jets: base16: failed\r\n");
|
||||
ret_i = 0;
|
||||
|
@ -20,7 +20,8 @@
|
||||
u3_csat_init = 0, // initialized
|
||||
u3_csat_addr = 1, // address resolution begun
|
||||
u3_csat_quit = 2, // cancellation requested
|
||||
u3_csat_ripe = 3 // passed to libh2o
|
||||
u3_csat_conn = 3, // sync connect phase
|
||||
u3_csat_ripe = 4 // passed to libh2o
|
||||
} u3_csat;
|
||||
|
||||
/* u3_cres: response to http client.
|
||||
@ -35,26 +36,26 @@
|
||||
/* u3_creq: outgoing http request.
|
||||
*/
|
||||
typedef struct _u3_creq { // client request
|
||||
c3_l num_l; // request number
|
||||
c3_l num_l; // request number
|
||||
h2o_http1client_t* cli_u; // h2o client
|
||||
u3_csat sat_e; // connection state
|
||||
c3_o sec; // yes == https
|
||||
c3_w ipf_w; // IP
|
||||
c3_c* ipf_c; // IP (string)
|
||||
c3_c* hot_c; // host
|
||||
c3_s por_s; // port
|
||||
c3_c* por_c; // port (string)
|
||||
c3_c* met_c; // method
|
||||
c3_c* url_c; // url
|
||||
u3_hhed* hed_u; // headers
|
||||
u3_hbod* bod_u; // body
|
||||
u3_hbod* rub_u; // exit of send queue
|
||||
u3_hbod* bur_u; // entry of send queue
|
||||
h2o_iovec_t* vec_u; // send-buffer array
|
||||
u3_cres* res_u; // nascent response
|
||||
struct _u3_creq* nex_u; // next in list
|
||||
struct _u3_creq* pre_u; // previous in list
|
||||
struct _u3_cttp* ctp_u; // cttp backpointer
|
||||
u3_csat sat_e; // connection state
|
||||
c3_o sec; // yes == https
|
||||
c3_w ipf_w; // IP
|
||||
c3_c* ipf_c; // IP (string)
|
||||
c3_c* hot_c; // host
|
||||
c3_s por_s; // port
|
||||
c3_c* por_c; // port (string)
|
||||
c3_c* met_c; // method
|
||||
c3_c* url_c; // url
|
||||
u3_hhed* hed_u; // headers
|
||||
u3_hbod* bod_u; // body
|
||||
u3_hbod* rub_u; // exit of send queue
|
||||
u3_hbod* bur_u; // entry of send queue
|
||||
h2o_iovec_t* vec_u; // send-buffer array
|
||||
u3_cres* res_u; // nascent response
|
||||
struct _u3_creq* nex_u; // next in list
|
||||
struct _u3_creq* pre_u; // previous in list
|
||||
struct _u3_cttp* ctp_u; // cttp backpointer
|
||||
} u3_creq;
|
||||
|
||||
/* u3_cttp: http client.
|
||||
@ -559,13 +560,18 @@ _cttp_creq_new(u3_cttp* ctp_u, c3_l num_l, u3_noun hes)
|
||||
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));
|
||||
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);
|
||||
return 0;
|
||||
}
|
||||
|
||||
u3_noun pul = u3t(unit_pul);
|
||||
|
||||
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
|
||||
_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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// serialize request (populate rub_u)
|
||||
//
|
||||
_cttp_creq_fire(ceq_u);
|
||||
|
||||
{
|
||||
c3_w 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"));
|
||||
}
|
||||
|
||||
@ -842,24 +861,43 @@ _cttp_creq_on_connect(h2o_http1client_t* cli_u, const c3_c* err_c,
|
||||
static void
|
||||
_cttp_creq_connect(u3_creq* ceq_u)
|
||||
{
|
||||
c3_assert(u3_csat_ripe == ceq_u->sat_e);
|
||||
c3_assert(ceq_u->ipf_c);
|
||||
c3_assert( u3_csat_conn == ceq_u->sat_e );
|
||||
c3_assert( ceq_u->ipf_c );
|
||||
|
||||
h2o_iovec_t ipf_u = h2o_iovec_init(ceq_u->ipf_c, strlen(ceq_u->ipf_c));
|
||||
c3_s por_s = ceq_u->por_s ? ceq_u->por_s :
|
||||
( c3y == ceq_u->sec ) ? 443 : 80;
|
||||
// connect by ip/port, avoiding synchronous getaddrinfo()
|
||||
//
|
||||
{
|
||||
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, ipf_u,
|
||||
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);
|
||||
h2o_http1client_connect(&ceq_u->cli_u, ceq_u, &ceq_u->ctp_u->ctx_u,
|
||||
ipf_u, por_s, tls_t, _cttp_creq_on_connect);
|
||||
}
|
||||
|
||||
_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
|
||||
@ -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_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);
|
||||
}
|
||||
|
||||
@ -926,7 +964,7 @@ static void
|
||||
_cttp_creq_start(u3_creq* ceq_u)
|
||||
{
|
||||
if ( ceq_u->ipf_c ) {
|
||||
ceq_u->sat_e = u3_csat_ripe;
|
||||
ceq_u->sat_e = u3_csat_conn;
|
||||
_cttp_creq_connect(ceq_u);
|
||||
} else {
|
||||
ceq_u->sat_e = u3_csat_addr;
|
||||
@ -981,7 +1019,6 @@ _cttp_ef_http_client(u3_cttp* ctp_u, u3_noun tag, u3_noun dat)
|
||||
ret_o = c3y;
|
||||
}
|
||||
else {
|
||||
u3l_log("cttp: strange request (unparsable url)\n");
|
||||
ret_o = c3n;
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user