Add 'webapp/' from commit '3bb5ed17be8cd990fad40b4c244cbc8076838392'

git-subtree-dir: webapp
git-subtree-mainline: 7ebd80d792
git-subtree-split: 3bb5ed17be
master
alex 2 years ago
commit 5ad5c166ea

@ -0,0 +1 @@
EXTEND_ESLINT=true

@ -0,0 +1,113 @@
module.exports = {
"env": {
"shared-node-browser": true,
"es6": true,
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"settings": {
"react": {
"version": "detect",
},
},
"plugins": [
"@typescript-eslint",
],
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/member-delimiter-style": ["error", {
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
}
}],
"@typescript-eslint/no-unused-vars": ["warn", {
"vars": "all",
"args": "all",
"ignoreRestSiblings": true,
"argsIgnorePattern": "^_",
}],
"react/display-name": "off",
"react/no-unescaped-entities": "off",
"react/jsx-tag-spacing": ["error", {
"closingSlash": "never",
"beforeSelfClosing": "always",
"afterOpening": "never",
"beforeClosing": "never"
}],
"react/jsx-boolean-value": ["error", "never"],
"react/jsx-curly-spacing": ["error", { "when": "never", "children": true }],
"react/jsx-equals-spacing": ["error", "never"],
"react/jsx-indent-props": ["error", 2],
"react/jsx-curly-brace-presence": ["error", "never"],
"react/jsx-key": ["error", { "checkFragmentShorthand": true }],
"react/jsx-indent": ["error", 2, { checkAttributes: true, indentLogicalExpressions: true }],
"react/void-dom-elements-no-children": ["error"],
"react/no-unknown-property": ["error"],
"quotes": "off",
"@typescript-eslint/quotes": ["error", "double", { "allowTemplateLiterals": true, "avoidEscape": true }],
"semi": "off",
"@typescript-eslint/semi": ["error", "always", { "omitLastInOneLineBlock": true }],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "never"
}],
"comma-spacing": ["error"],
"eqeqeq": ["error", "smart"],
"indent": "off",
"@typescript-eslint/indent": ["error", 2, {
"SwitchCase": 1,
}],
"no-multi-spaces": "error",
"object-curly-spacing": ["error", "always"],
"arrow-parens": "error",
"arrow-spacing": "error",
"key-spacing": "error",
"keyword-spacing": "error",
"func-call-spacing": "off",
"@typescript-eslint/func-call-spacing": ["error"],
"space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}],
"space-in-parens": ["error", "never"],
"space-before-blocks": "error",
"curly": ["error", "all"],
"space-infix-ops": "error",
"consistent-return": "error",
"jsx-quotes": ["error"],
"array-bracket-spacing": "error",
"brace-style": "off",
"@typescript-eslint/brace-style": [
"error",
"1tbs",
{ allowSingleLine: true },
],
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "warn",
}
};

@ -0,0 +1,2 @@
github: etesync
custom: https://www.etesync.com/contribute/#donate

23
webapp/.gitignore vendored

@ -0,0 +1,23 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.*.swp
Session.vim
npm-debug.log*
yarn-debug.log*
yarn-error.log*

@ -0,0 +1,6 @@
Tom Hacohen <tom@stosb.com>
Tal Leibman <leibman2@gmail.com>
rugk <rugk@posteo.de>
Andrew P Maney <amaney@usc.edu>
Bryce McNab <betsythefc@users.noreply.github.com>
Claus Niesen <cniesen@users.noreply.github.com>

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

@ -0,0 +1,55 @@
<p align="center">
<img width="120" src="src/images/logo.svg" />
<h1 align="center">EteSync - Encrypt Everything</h1>
</p>
The EteSync Web App - Use EteSync from the browser!
![GitHub tag](https://img.shields.io/github/tag/etesync/etesync-web.svg)
[![Chat on freenode](https://img.shields.io/badge/irc.freenode.net-%23EteSync-blue.svg)](https://webchat.freenode.net/?channels=#etesync)
For notes, please refer to [the EteSync Notes](https://github.com/etesync/etesync-notes/) repository.
# Usage
A live instance is available on: https://pim.etesync.com
Please be advised that while it's probably safe enough to use the hosted client
in many cases, it's generally not preferable. It's recommended that you use signed
releases which's signature you manually verify and are run locally!
More info is available on the [FAQ](https://www.etesync.com/faq/#web-client).
## Running your own
You can either self-host your own client to be served from your own server, or
better yet, just run an instance locally.
You can get the latest version of the web client from https://pim.etesync.com/etesync-web.tgz. This
file is automatically generated on each deploy and is exactly the same as the delpoyed version.
After fetching this file you need to extract it by e.g. running `tar -xzf etesync-web.tgz`, and then
you can serve the files using your favourite web server. Please keep in mind that opening the HTML files
directly in the browser is not supported.
If you are just serving the app locally, you could, for example, use the python built-in web server by
running `python3 -m http.server` from inside the extracted `etesync-web` directory. If you plan on
serving it from a server, please use a proper web server such as nginx.
## Building it yourself
Before you can build the web app from source, you need to make sure you have `yarn` install.
Then clone this repository `yarn`, run `yarn` and wait until all of the deps are installed.
Then it's recommended you run `yarn build` to build a production ready client you should serve
(even if run locally!) and then just serve the `build` directory from a web server.
The URL of the EteSync API the web app connects to defaults to `api.etebase.com`, but can be changed on
the login page. You can change this default by setting the environment variable `REACT_APP_DEFAULT_API_PATH`
during the build. This can be useful for self-hosting. You can set the default URL to the address
of your self-hosted EteSync server so you don't have to change the address for every login.
### Serving from a subdirectory
In order to run your own version and serve it from a subdirectory rather than the top level of the domain, add `"homepage": "/subdir-name"` to the `package.json` file.

@ -0,0 +1,18 @@
set -e
SSH_HOST=client.etesync.com
SSH_PORT=22
SSH_USER=etesync
SSH_TARGET_DIR=sites/pim.etesync.com
OUTPUTDIR=./build
export INLINE_RUNTIME_CHUNK=false
yarn build
sed -i "s#\(<script type=\"text/javascript\"\)#\1 integrity=\"sha384-$(shasum -b -a 384 build/static/js/main.*.js | xxd -r -p | base64 -w0)\" crossorigin=\"anonymous\"#" build/index.html
./page-signer.js build/index.html build/index.html
# Create a source tarball
bsdtar -czf build/etesync-web.tgz --exclude build/etesync-web.tgz -s /build/etesync-web/ build/*
rsync -e "ssh -p ${SSH_PORT}" -P --delete -rvc -zz ${OUTPUTDIR}/ ${SSH_USER}@${SSH_HOST}:${SSH_TARGET_DIR}

@ -0,0 +1,71 @@
{
"name": "etesync-web",
"version": "0.6.0",
"private": true,
"dependencies": {
"@date-io/moment": "^1.x",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"@material-ui/pickers": "^3.2.10",
"@material-ui/styles": "^4.10.0",
"etebase": "^0.41.0",
"fuse.js": "^5.0.9-beta",
"ical.js": "^1.4.0",
"immutable": "^4.0.0-rc.12",
"localforage": "^1.9.0",
"memoizee": "^0.4.14",
"moment": "^2.27.0",
"react": "^16.13.1",
"react-big-calendar": "^0.26.0",
"react-dom": "^16.13.1",
"react-dropzone": "^10.0.4",
"react-redux": "^7.2.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.1",
"react-transition-group": "^4.3.0",
"react-virtualized": "^9.21.2",
"redux": "^4.0.5",
"redux-actions": "^2.6.5",
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"uuid": "^3.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"lint": "eslint --ext .js,.jsx,.ts,.tsx src",
"eject": "react-scripts eject"
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/color": "^3.0.1",
"@types/jest": "^24.0.4",
"@types/memoizee": "^0.4.4",
"@types/node": "^11.9.3",
"@types/react": "^16.9.0",
"@types/react-big-calendar": "^0.22.3",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.9",
"@types/react-router": "^5.1.8",
"@types/react-router-dom": "^5.1.5",
"@types/react-virtualized": "^9.21.8",
"@types/redux-actions": "^2.6.1",
"@types/redux-logger": "^3.0.8",
"@types/urijs": "^1.15.38",
"@types/uuid": "^3.4.3",
"typescript": "~3.9.7"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@ -0,0 +1,50 @@
<!doctype html>
<!--!
%%%SIGNED_PAGES_PGP_SIGNATURE%%%
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#ffc107">
<meta name="google" content="notranslate">
<meta name="referrer" content="no-referrer">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="EteSync">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<link rel="apple-touch-icon" size="192x192" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>EteSync - Secure Data Sync</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

@ -0,0 +1,47 @@
{
"short_name": "EteSync",
"name": "EteSync - Secure Data Sync",
"description": "Secure, end-to-end encrypted, and privacy respecting sync for your contacts, calendars, tasks, and notes.",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
}
],
"start_url": "https://pim.etesync.com",
"display": "standalone",
"theme_color": "#ffc107",
"background_color": "#03a9f4",
"categories": [
"productivity",
"utilities"
],
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.etesync.syncadapter",
"id": "com.etesync.syncadapter"
}, {
"platform": "itunes",
"url": "https://apps.apple.com/us/app/apple-store/id1489574285"
}, {
"platform": "f-droid",
"url": "https://f-droid.org/packages/com.etesync.syncadapter/",
"id": "com.etesync.syncadapter"
}
],
"shortcuts": [
{
"name": "Calendar",
"url": "/pim/events"
}, {
"name": "Tasks",
"url": "/pim/tasks"
}, {
"name": "Address Book",
"url": "/pim/contacts"
}
]
}

@ -0,0 +1 @@
export PATH="${HOME}/.npm-packages/bin:${PATH}"

@ -0,0 +1,29 @@
.App {
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 80px;
}
.App-header {
background-color: #222;
height: 150px;
padding: 20px;
color: white;
}
.App-intro {
font-size: large;
}
.App-drawer-header {
background-color: #555;
padding: 10px;
}
.App-drawer-logo {
width: 60px;
margin-bottom: 10px;
}

@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Provider } from "react-redux";
import App from "./App";
import { store } from "./store";
it("renders without crashing", () => {
const div = document.createElement("div");
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>
, div);
});

@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
import { BrowserRouter } from "react-router-dom";
import { MuiThemeProvider as ThemeProvider, createMuiTheme } from "@material-ui/core/styles"; // v1.x
import amber from "@material-ui/core/colors/amber";
import lightBlue from "@material-ui/core/colors/lightBlue";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Drawer from "@material-ui/core/Drawer";
import IconButton from "@material-ui/core/IconButton";
import Badge from "@material-ui/core/Badge";
import NavigationMenu from "@material-ui/icons/Menu";
import NavigationRefresh from "@material-ui/icons/Refresh";
import ErrorsIcon from "@material-ui/icons/Error";
import "react-virtualized/styles.css"; // only needs to be imported once
import "./App.css";
import ConfirmationDialog from "./widgets/ConfirmationDialog";
import { List, ListItem } from "./widgets/List";
import withSpin from "./widgets/withSpin";
import ErrorBoundary from "./components/ErrorBoundary";
import SideMenu from "./SideMenu";
import { RouteResolver } from "./routes";
import * as store from "./store";
import * as actions from "./store/actions";
import { useCredentials } from "./credentials";
import { SyncManager } from "./sync/SyncManager";
import MainRouter from "./MainRouter";
export const routeResolver = new RouteResolver({
home: "",
pim: {
contacts: {
_id: {
_base: ":itemUid",
edit: {
contact: "contact",
group: "group",
},
log: "log",
},
new: {
contact: "contact",
group: "group",
},
},
events: {
_id: {
_base: ":itemUid",
edit: "edit",
duplicate: "duplicate",
log: "log",
},
new: "new",
},
tasks: {
_id: {
_base: ":itemUid",
edit: "edit",
log: "log",
},
new: "new",
},
},
collections: {
_id: {
_base: ":colUid",
edit: "edit",
items: {
_id: {
_base: ":itemUid",
},
},
entries: {
_id: {
_base: ":entryUid",
},
},
members: {
},
},
invitations: {
incoming: {
},
outgoing: {
},
},
new: "new",
import: "import",
},
login: {
},
signup: {
},
wizard: {
},
settings: {
},
debug: {
},
});
interface AppBarPropsType {
toggleDrawerIcon: any;
iconElementRight: any;
}
function AppBarWitHistory(props: AppBarPropsType) {
const {
toggleDrawerIcon,
iconElementRight,
...rest
} = props;
return (
<AppBar
position="static"
{...rest}
>
<Toolbar>
<div style={{ marginLeft: -12, marginRight: 20 }}>
{toggleDrawerIcon}
</div>
<div style={{ flexGrow: 1, fontSize: "1.25em" }} id="appbar-title" />
<div style={{ marginRight: -12 }} id="appbar-buttons">
{iconElementRight}
</div>
</Toolbar>
</AppBar>
);
}
const IconRefreshWithSpin = withSpin(NavigationRefresh);
export default function App() {
const [drawerOpen, setDrawerOpen] = React.useState(false);
const [errorsDialog, setErrorsDialog] = React.useState(false);
const dispatch = useDispatch();
const etebase = useCredentials();
const darkMode = useSelector((state: store.StoreState) => state.settings.darkMode);
const fetchCount = useSelector((state: store.StoreState) => state.fetchCount);
const errors = useSelector((state: store.StoreState) => state.errors);
async function refresh() {
const syncManager = SyncManager.getManager(etebase!);
const sync = syncManager.sync();
dispatch(actions.performSync(sync));
await sync;
}
function autoRefresh() {
if (navigator.onLine && etebase) {
refresh();
}
}
React.useEffect(() => {
const interval = 5 * 60 * 1000;
const id = setInterval(autoRefresh, interval);
return () => clearInterval(id);
}, []);
function toggleDrawer() {
setDrawerOpen(!drawerOpen);
}
function closeDrawer() {
setDrawerOpen(false);
}
const credentials = etebase ?? null;
const fetching = fetchCount > 0;
const muiTheme = createMuiTheme({
palette: {
type: darkMode ? "dark" : undefined,
primary: amber,
secondary: {
light: lightBlue.A200,
main: lightBlue.A400,
dark: lightBlue.A700,
contrastText: "#fff",
},
},
});
const styles: {[key: string]: React.CSSProperties} = {
main: {
backgroundColor: muiTheme.palette.background.default,
color: muiTheme.palette.text.primary,
flexGrow: 1,
display: "flex",
flexDirection: "column",
},
};
return (
<ThemeProvider theme={muiTheme}>
<BrowserRouter>
<div style={styles.main} className={darkMode ? "theme-dark" : "theme-light"}>
<AppBarWitHistory
toggleDrawerIcon={<IconButton onClick={toggleDrawer}><NavigationMenu /></IconButton>}
iconElementRight={
<>
{(errors.size > 0) && (
<IconButton onClick={() => setErrorsDialog(true)} title="Errors">
<Badge badgeContent={errors.size} color="error">
<ErrorsIcon />
</Badge>
</IconButton>
)}
<IconButton disabled={!credentials || fetching} onClick={refresh} title="Refresh">
<IconRefreshWithSpin spin={fetching} />
</IconButton>
</>
}
/>
<ConfirmationDialog
title="Sync Errors"
open={errorsDialog}
labelOk="OK"
onCancel={() => setErrorsDialog(false)}
onOk={() => setErrorsDialog(false)}
>
<h4>
Please contact developers if any of the errors below persist.
</h4>
<List>
{errors.map((error, index) => (
<ListItem
key={index}
style={{ height: "unset" }}
onClick={() => (window as any).navigator.clipboard.writeText(`${error.message}\n\n${error.stack}`)}
>
{error.message}
</ListItem>
))}
</List>
</ConfirmationDialog>
<Drawer
open={drawerOpen}
onClose={toggleDrawer}
>
<SideMenu onCloseDrawerRequest={closeDrawer} />
</Drawer>
<ErrorBoundary>
<MainRouter />
</ErrorBoundary>
</div>
</BrowserRouter>
</ThemeProvider>
);
}

@ -0,0 +1,9 @@
/* Hack the layout on mobile. */
@media (max-width: 767px) {
.rbc-toolbar {
flex-direction: column;
}
.rbc-toolbar-label {
margin: 10px 0;
}
}

@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Calendar as BigCalendar, momentLocalizer, View } from "react-big-calendar";
import "react-big-calendar/lib/css/react-big-calendar.css";
import moment from "moment";
import * as ICAL from "ical.js";
import { store } from "../store";
import { appendError } from "../store/actions";
import { EventType } from "../pim-types";
import "./Calendar.css";
const calendarLocalizer = momentLocalizer(moment);
const MAX_RECURRENCE_DATE = ICAL.Time.now();
MAX_RECURRENCE_DATE.adjust(800, 0, 0, 0);
function eventPropGetter(event: EventType) {
return {
style: {
backgroundColor: event.color,
},
};
}
function agendaHeaderFormat(date: {start: Date, end: Date}, _culture: string, localizer: any) {
const format = "ll";
return localizer.format(date.start, format) + " - " + localizer.format(date.end, format);
}
interface PropsType {
entries: EventType[];
onItemClick: (contact: EventType) => void;
onSlotClick?: (start: Date, end: Date) => void;
}
class Calendar extends React.PureComponent<PropsType> {
public state: {
currentDate?: Date;
view?: View;
};
constructor(props: any) {
super(props);
this.state = {};
this.onNavigate = this.onNavigate.bind(this);
this.onView = this.onView.bind(this);
this.slotClicked = this.slotClicked.bind(this);
}
public render() {
const entries = [] as EventType[];
this.props.entries.forEach((event) => {
entries.push(event);
try {
if (event.isRecurring()) {
const recur = event.iterator();
let next = recur.next(); // Skip the first one
while ((next = recur.next())) {
if (next.compare(MAX_RECURRENCE_DATE) > 0) {
break;
}
const shift = next.subtractDateTz(event.startDate);
const ev = event.clone();
ev.startDate.addDuration(shift);
ev.endDate.addDuration(shift);
entries.push(ev);
}
}
} catch (e) {
store.dispatch(appendError(e));
}
});
return (
<div style={{ width: "100%", height: "calc(100vh - 230px)", minHeight: 500 }}>
<BigCalendar
defaultDate={new Date()}
scrollToTime={new Date(1970, 1, 1, 8)}
localizer={calendarLocalizer}
events={entries}
selectable
onSelectEvent={this.props.onItemClick as any}
onSelectSlot={this.slotClicked as any}
formats={{ agendaHeaderFormat: agendaHeaderFormat as any }}
eventPropGetter={eventPropGetter}
date={this.state.currentDate}
onNavigate={this.onNavigate}
view={this.state.view}
onView={this.onView}
/>
</div>
);
}
private onNavigate(currentDate: Date) {
this.setState({ currentDate });
}
private onView(view: string) {
this.setState({ view });
}
private slotClicked(slotInfo: {start: Date, end: Date}) {
if (this.props.onSlotClick) {
this.props.onSlotClick(slotInfo.start, slotInfo.end);
}
}
}
export default Calendar;

@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import PimItemHeader from "../components/PimItemHeader";
import { formatDateRange, formatOurTimezoneOffset } from "../helpers";
import { EventType } from "../pim-types";
class Event extends React.PureComponent {
public props: {
item?: EventType;
};
public render() {
if (this.props.item === undefined) {
throw Error("Event should be defined!");
}
const style = {
content: {
padding: 15,
},
};
const timezone = this.props.item.timezone;
return (
<React.Fragment>
<PimItemHeader text={this.props.item.summary} backgroundColor={this.props.item.color}>
<div>{formatDateRange(this.props.item.startDate, this.props.item.endDate)} {timezone && <small>({formatOurTimezoneOffset()})</small>}</div>
<br />
<div><u>{this.props.item.location}</u></div>
</PimItemHeader>
<div style={style.content}>
<p style={{ wordWrap: "break-word" }}>{this.props.item.description}</p>
{(this.props.item.attendees.length > 0) && (
<div>Attendees: {this.props.item.attendees.map((x) => (x.getFirstValue())).join(", ")}</div>)}
</div>
</React.Fragment>
);
}
}
export default Event;

@ -0,0 +1,436 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import FormGroup from "@material-ui/core/FormGroup";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import Switch from "@material-ui/core/Switch";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import FormHelperText from "@material-ui/core/FormHelperText";
import InputLabel from "@material-ui/core/InputLabel";
import * as colors from "@material-ui/core/colors";
import IconDelete from "@material-ui/icons/Delete";
import IconCancel from "@material-ui/icons/Clear";
import IconSave from "@material-ui/icons/Save";
import DateTimePicker from "../widgets/DateTimePicker";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import TimezonePicker from "../widgets/TimezonePicker";
import Toast from "../widgets/Toast";
import * as uuid from "uuid";
import * as ICAL from "ical.js";
import { getCurrentTimezone } from "../helpers";
import { EventType, timezoneLoadFromName } from "../pim-types";
import RRule, { RRuleOptions } from "../widgets/RRule";
import { History } from "history";
import { CachedCollection } from "../Pim/helpers";
interface PropsType {
collections: CachedCollection[];
initialCollection?: string;
item?: EventType;
onSave: (event: EventType, collectionUid: string, originalEvent?: EventType) => Promise<void>;
onDelete: (event: EventType, collectionUid: string) => void;
onCancel: () => void;
history: History;
duplicate?: boolean;
}
export default class EventEdit extends React.PureComponent<PropsType> {
public state: {
uid: string;
title: string;
description: string;
allDay: boolean;
start?: Date;
end?: Date;
timezone: string | null;
rrule?: RRuleOptions;
location: string;
collectionUid: string;
error?: string;
showDeleteDialog: boolean;
};
constructor(props: PropsType) {
super(props);
this.state = {
uid: "",
title: "",
allDay: false,
location: "",
description: "",
timezone: null,
collectionUid: "",
showDeleteDialog: false,
};
const locState = this.props.history.location.state as EventType;
if (locState) {
// FIXME: Hack to determine if all day. Should be passed as a proper state.
this.state.allDay = (locState.start &&
(locState.start.getHours() === 0) &&
(locState.start.getMinutes() === 0) &&
(locState.start.getHours() === locState.end.getHours()) &&
(locState.start.getMinutes() === locState.end.getMinutes()));
this.state.start = (locState.start) ? locState.start : undefined;
this.state.end = (locState.end) ? locState.end : undefined;
}
if (this.props.item !== undefined) {
const event = this.props.item;
const allDay = event.startDate.isDate;
const endDate = event.endDate.clone();
if (allDay) {
endDate.adjust(-1, 0, 0, 0);
}
if (this.props.duplicate) {
this.state.title = event.title ? `Copy of ${event.title}` : "";
} else {
this.state.uid = event.uid;
this.state.title = event.title ? event.title : "";
}
this.state.allDay = allDay;
this.state.start = event.startDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
this.state.end = endDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
this.state.location = event.location ? event.location : "";
this.state.description = event.description ? event.description : "";
this.state.timezone = event.timezone;
const rruleProp = this.props.item?.component.getFirstPropertyValue<ICAL.Recur>("rrule");
if (rruleProp) {
this.state.rrule = rruleProp.toJSON() as any;
if (this.state.rrule && rruleProp.until) {
this.state.rrule.until = rruleProp.until;
}
}
}
if (this.props.duplicate || this.props.item === undefined) {
this.state.uid = uuid.v4();
}
this.state.timezone = this.state.timezone || getCurrentTimezone();
if (props.initialCollection) {
this.state.collectionUid = props.initialCollection;
} else if (props.collections[0]) {
this.state.collectionUid = props.collections[0].collection.uid;
}
this.onSubmit = this.onSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.toggleAllDay = this.toggleAllDay.bind(this);
this.onDeleteRequest = this.onDeleteRequest.bind(this);
this.toggleRecurring = this.toggleRecurring.bind(this);
this.handleRRuleChange = this.handleRRuleChange.bind(this);
this.handleCloseToast = this.handleCloseToast.bind(this);
}
public handleChange(name: string, value: string) {
this.setState({
[name]: value,
});
}
public handleInputChange(event: React.ChangeEvent<any>) {
const name = event.target.name;
const value = event.target.value;
this.handleChange(name, value);
}
public toggleAllDay() {
this.setState({ allDay: !this.state.allDay });
}
public toggleRecurring() {
const value = this.state.rrule ? undefined : { freq: "WEEKLY", interval: 1 };
this.setState({ rrule: value });
}
public handleRRuleChange(rrule: RRuleOptions): void {
this.setState({ rrule: rrule });
}
public handleCloseToast(_event?: React.SyntheticEvent, reason?: string) {
if (reason === "clickaway") {
return;
}
this.setState({ error: "" });
}
public onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
if ((!this.state.start) || (!this.state.end)) {
this.setState({ error: "Both start and end time must be set!" });
return;
}
function fromDate(date: Date, allDay: boolean) {
const ret = ICAL.Time.fromJSDate(date, false);
if (!allDay) {
return ret;
} else {
const data = ret.toJSON();
data.isDate = allDay;
return ICAL.Time.fromData(data);
}
}
const startDate = fromDate(this.state.start, this.state.allDay);
const endDate = fromDate(this.state.end, this.state.allDay);
if (this.state.allDay) {
endDate.adjust(1, 0, 0, 0);
}
if (startDate.compare(endDate) >= 0) {
this.setState({ error: "End time must be later than start time!" });
return;
}
const event = (this.props.item && !this.props.duplicate) ?
this.props.item.clone()
:
new EventType()
;
event.uid = this.state.uid;
event.summary = this.state.title;
event.startDate = startDate;
event.endDate = endDate;
event.location = this.state.location;
event.description = this.state.description;
if (this.state.timezone) {
const timezone = timezoneLoadFromName(this.state.timezone);
if (timezone) {
event.startDate = event.startDate?.convertToZone(timezone);
event.endDate = event.endDate?.convertToZone(timezone);
}
}
if (this.state.rrule) {
event.component.updatePropertyWithValue("rrule", new ICAL.Recur(this.state.rrule!));
}
event.component.updatePropertyWithValue("last-modified", ICAL.Time.now());
this.props.onSave(event, this.state.collectionUid, this.props.item)
.then(() => {
this.props.history.goBack();
});
}
public onDeleteRequest() {
this.setState({
showDeleteDialog: true,
});
}
public render() {
const styles = {
form: {
},
fullWidth: {
width: "100%",
boxSizing: "border-box" as any,
marginTop: 16,
},
submit: {
marginTop: 40,
marginBottom: 20,
textAlign: "right" as any,
},
};
const recurring = this.props.item && this.props.item.isRecurring();
const differentTimezone = this.state.timezone && (this.state.timezone !== getCurrentTimezone()) && timezoneLoadFromName(this.state.timezone);
return (
<>
<h2>
{(this.props.item && !this.props.duplicate) ? "Edit Event" : "New Event"}
</h2>
{recurring && (
<div>
<span style={{ color: "red" }}>IMPORTANT: </span>
This is a recurring event, for now, only editing the whole series
(by editing the first instance) is supported.
</div>
)}
<Toast open={!!this.state.error} onClose={this.handleCloseToast}>
ERROR! {this.state.error}
</Toast>
<form style={styles.form} onSubmit={this.onSubmit}>
<TextField
name="title"
placeholder="Enter title"
style={styles.fullWidth}
value={this.state.title}
onChange={this.handleInputChange}
/>
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
<InputLabel>
Saving to
</InputLabel>
<Select
name="collectionUid"
value={this.state.collectionUid}
disabled={this.props.item !== undefined && !this.props.duplicate}
onChange={this.handleInputChange}
>
{this.props.collections.map((x) => (
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl>
<FormHelperText>FROM</FormHelperText>
<DateTimePicker
dateOnly={this.state.allDay}
placeholder="Start"
value={this.state.start}
onChange={(date?: Date) => {
// If end is unset, set it to start + 30 minutes
const end = this.state.end ?? (
new Date(date!.getTime() + 30 * 60 * 1000)
);
this.setState({ start: date, end });
}}
/>
{differentTimezone && this.state.start && (
<FormHelperText>{ICAL.Time.fromJSDate(this.state.start, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
)}
</FormControl>
<FormControl>
<FormHelperText>TO</FormHelperText>
<DateTimePicker
dateOnly={this.state.allDay}
placeholder="End"
value={this.state.end}
onChange={(date?: Date) => this.setState({ end: date })}
/>
{differentTimezone && this.state.end && (
<FormHelperText>{ICAL.Time.fromJSDate(this.state.end, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
)}
</FormControl>
<FormGroup>
<FormControlLabel
control={
<Switch
name="allDay"
checked={this.state.allDay}
onChange={this.toggleAllDay}
color="primary"
/>
}
label="All Day"
/>
</FormGroup>
{(!this.state.allDay) && (
<TimezonePicker value={this.state.timezone} onChange={(zone) => this.setState({ timezone: zone })} />
)}
<TextField
name="location"
placeholder="Add location"
style={styles.fullWidth}
value={this.state.location}
onChange={this.handleInputChange}
/>
<TextField
name="description"
placeholder="Add description"
multiline
style={styles.fullWidth}
value={this.state.description}
onChange={this.handleInputChange}
/>
<FormGroup>
<FormControlLabel
control={
<Switch
name="recurring"
checked={!!this.state.rrule}
onChange={this.toggleRecurring}
color="primary"
/>
}
label="Recurring"
/>
</FormGroup>
{this.state.rrule &&
<RRule
onChange={this.handleRRuleChange}
rrule={this.state.rrule ? this.state.rrule : { freq: "DAILY", interval: 1 }}
/>
}
<div style={styles.submit}>
<Button
variant="contained"
onClick={this.props.onCancel}
>
<IconCancel style={{ marginRight: 8 }} />
Cancel
</Button>
{this.props.item &&
<Button
variant="contained"
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
onClick={this.onDeleteRequest}
>
<IconDelete style={{ marginRight: 8 }} />
Delete
</Button>
}
<Button
type="submit"
variant="contained"
color="secondary"
style={{ marginLeft: 15 }}
>
<IconSave style={{ marginRight: 8 }} />
Save
</Button>
</div>
</form>
<ConfirmationDialog
title="Delete Confirmation"
labelOk="Delete"
open={this.state.showDeleteDialog}
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
onCancel={() => this.setState({ showDeleteDialog: false })}
>
Are you sure you would like to delete this event?
</ConfirmationDialog>
</>
);
}
}

@ -0,0 +1,251 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Switch, Route, useHistory } from "react-router";
import * as Etebase from "etebase";
import { Button, useTheme } from "@material-ui/core";
import IconEdit from "@material-ui/icons/Edit";
import IconDuplicate from "@material-ui/icons/FileCopy";
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
import { EventType, PimType } from "../pim-types";
import { useCredentials } from "../credentials";
import { useItems, useCollections } from "../etebase-helpers";
import { routeResolver } from "../App";
import Calendar from "./Calendar";
import Event from "./Event";
import LoadingIndicator from "../widgets/LoadingIndicator";
import EventEdit from "./EventEdit";
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemDelete, itemSave, defaultColor } from "../Pim/helpers";
import { historyPersistor } from "../persist-state-history";
import ItemChangeHistory from "../Pim/ItemChangeHistory";
const PersistCalendar = historyPersistor("Calendar")(Calendar);
const colType = "etebase.vevent";
const decryptCollections = getDecryptCollectionsFunction(colType);
const decryptItems = getDecryptItemsFunction(colType, EventType.parse);
export default function CalendarsMain() {
const [entries, setEntries] = React.useState<Map<string, Map<string, EventType>>>();
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
const theme = useTheme();
const history = useHistory();
const etebase = useCredentials()!;
const collections = useCollections(etebase, colType);
const items = useItems(etebase, colType);
React.useEffect(() => {
if (!collections || !items) {
return;
}
(async () => {
const colEntries = await decryptCollections(collections);
const entries = await decryptItems(items);
for (const collection of colEntries) {
const items = entries.get(collection.collection.uid)!;
for (const item of items.values()) {
item.color = collection.metadata.color || defaultColor;
}
}
setCachedCollections(colEntries);
setEntries(entries);
})();
}, [items, collections]);
if (!entries || !cachedCollections) {
return (
<LoadingIndicator />
);
}
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
const collection = collections!.find((x) => x.uid === collectionUid)!;
await itemSave(etebase, collection, items!, item, collectionUid, originalItem);
}
async function onItemDelete(item: PimType, collectionUid: string) {
const collection = collections!.find((x) => x.uid === collectionUid)!;
await itemDelete(etebase, collection, items!, item, collectionUid);
history.push(routeResolver.getRoute("pim.events"));
}
function onCancel() {
history.goBack();
}
const flatEntries = [];
for (const col of entries.values()) {
for (const item of col.values()) {
flatEntries.push(item);
}
}
const styles = {
button: {
marginLeft: theme.spacing(1),
},
leftIcon: {
marginRight: theme.spacing(1),
},
};
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.events")}
exact
>
<PersistCalendar
entries={flatEntries}
onItemClick={(item: EventType) => history.push(
routeResolver.getRoute("pim.events._id", { itemUid: getItemNavigationUid(item) })
)}
onSlotClick={(start?: Date, end?: Date) => history.push(
routeResolver.getRoute("pim.events.new"),
{ start, end }
)}
/>
<PimFab
onClick={() => history.push(
routeResolver.getRoute("pim.events.new")
)}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.events.new")}
exact
>
<EventEdit
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.events._id.log")}
render={({ match }) => {
// We have this path outside because we don't want the item existing check
const [colUid, itemUid] = match.params.itemUid.split("|");
const cachedCollection = cachedCollections!.find((x) => x.collection.uid === colUid)!;
if (!cachedCollection) {
return (<PageNotFound />);
}
return (
<ItemChangeHistory collection={cachedCollection} itemUid={itemUid} />
);
}}
/>
<Route
path={routeResolver.getRoute("pim.events._id")}
render={({ match }) => {
const [colUid, itemUid] = match.params.itemUid.split("|");
const item = entries.get(colUid)?.get(itemUid);
if (!item) {
return (<PageNotFound />);
}
const collection = collections!.find((x) => x.uid === colUid)!;
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.events._id.edit")}
exact
>
<EventEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.events._id.duplicate")}
exact
>
<EventEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
duplicate
/>
</Route>
<Route
path={routeResolver.getRoute("pim.events._id")}
exact
>
<div style={{ textAlign: "right", marginBottom: 15 }}>
<Button
variant="contained"
style={styles.button}
onClick={() =>
history.push(routeResolver.getRoute("pim.events._id.log", { itemUid: getItemNavigationUid(item) }))
}
>
<IconChangeHistory style={styles.leftIcon} />
Change History
</Button>
<Button
color="secondary"
variant="contained"
disabled={readOnly}
style={{ ...styles.button, marginLeft: 15 }}
onClick={() =>
history.push(routeResolver.getRoute("pim.events._id.edit", { itemUid: getItemNavigationUid(item) }))
}
>
<IconEdit style={styles.leftIcon} />
Edit
</Button>
<Button
color="secondary"
variant="contained"
disabled={readOnly}
style={{ ...styles.button, marginLeft: 15 }}
onClick={() =>
history.push(routeResolver.getRoute("pim.events._id.duplicate", { itemUid: getItemNavigationUid(item) }))
}
>
<IconDuplicate style={styles.leftIcon} />
Duplicate
</Button>
</div>
<Event item={item} />
</Route>
<PageNotFoundRoute />
</Switch>
);
}}
/>
<PageNotFoundRoute />
</Switch>
);
}

@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import IconButton from "@material-ui/core/IconButton";
import IconEdit from "@material-ui/icons/Edit";
import IconMembers from "@material-ui/icons/People";
import IconImport from "@material-ui/icons/ImportExport";
import * as Etebase from "etebase";
import AppBarOverride from "../widgets/AppBarOverride";
import Container from "../widgets/Container";
import CollectionChangeEntries from "./CollectionChangeEntries";
import ImportDialog from "./ImportDialog";
import { Link } from "react-router-dom";
import { routeResolver } from "../App";
import { CachedCollection } from "../Pim/helpers";
interface PropsType {
collection: CachedCollection;
}
class Collection extends React.Component<PropsType> {
public state: {
tab: number;
importDialogOpen: boolean;
};
constructor(props: PropsType) {
super(props);
this.importDialogToggle = this.importDialogToggle.bind(this);
this.state = {
tab: 0,
importDialogOpen: false,
};
}
public render() {
const { collection, metadata } = this.props.collection;
const isAdmin = collection.accessLevel === Etebase.CollectionAccessLevel.Admin;
return (
<React.Fragment>
<AppBarOverride title={metadata.name!!}>
{isAdmin &&
<>
<IconButton
component={Link}
title="Edit"
{...{ to: routeResolver.getRoute("collections._id.edit", { colUid: collection.uid }) }}
>
<IconEdit />
</IconButton>
<IconButton
component={Link}
title="Members"
{...{ to: routeResolver.getRoute("collections._id.members", { colUid: collection.uid }) }}
>
<IconMembers />
</IconButton>
</>
}
<IconButton
title="Import"
onClick={this.importDialogToggle}
>
<IconImport />
</IconButton>
</AppBarOverride>
<Container>
<CollectionChangeEntries collection={this.props.collection} />
</Container>
<ImportDialog
key={this.state.importDialogOpen.toString()}
collection={this.props.collection}
open={this.state.importDialogOpen}
onClose={this.importDialogToggle}
/>
</React.Fragment>
);
}
private importDialogToggle() {
this.setState((state: any) => ({ importDialogOpen: !state.importDialogOpen }));
}
}
export default Collection;

@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import { useCredentials } from "../credentials";
import { useItems } from "../etebase-helpers";
import { CachedCollection, getRawItemNavigationUid } from "../Pim/helpers";
import LoadingIndicator from "../widgets/LoadingIndicator";
import GenericChangeHistory from "../components/GenericChangeHistory";
import { useHistory } from "react-router";
import { routeResolver } from "../App";
export interface CachedItem {
item: Etebase.Item;
metadata: Etebase.ItemMetadata;
content: string;
}
// FIXME: use the ones used by e.g. Contacts/Main so ew share the cache.
// Only problem though is that we want the deleted items here and not there.
async function decryptItems(items: Map<string, Map<string, Etebase.Item>>) {
const entries: Map<string, Map<string, CachedItem>> = new Map();
for (const [colUid, col] of items.entries()) {
const cur = new Map();
entries.set(colUid, cur);
for (const item of col.values()) {
cur.set(item.uid, {
item,
metadata: item.getMeta(),
content: await item.getContent(Etebase.OutputFormat.String),
});
}
}
return entries;
}
interface PropsType {
collection: CachedCollection;
}
export default function CollectionChangeEntries(props: PropsType) {
const [entries, setEntries] = React.useState<Map<string, CachedItem>>();
const history = useHistory();
const etebase = useCredentials()!;
const { collection, collectionType } = props.collection;
const items = useItems(etebase, collectionType);
React.useEffect(() => {
if (items) {
decryptItems(items)
.then((entries) => setEntries(entries.get(collection.uid)));
}
}, [items]);
if (!entries) {
return (
<LoadingIndicator />
);
}
const entriesList = Array.from(entries.values()).sort((a_, b_) => {
const a = a_.metadata.mtime ?? 0;
const b = b_.metadata.mtime ?? 0;
return a - b;
});
let changelogRoute = "";
switch (collectionType) {
case "etebase.vevent": {
changelogRoute = "pim.events._id.log";
break;
}
case "etebase.vtodo": {
changelogRoute = "pim.tasks._id.log";
break;
}
case "etebase.vcard": {
changelogRoute = "pim.contacts._id.log";
break;
}
}
return (
<div style={{ height: "calc(100vh - 300px)" }}>
<GenericChangeHistory
items={entriesList}
onItemClick={(item) =>
history.push(routeResolver.getRoute(changelogRoute, { itemUid: getRawItemNavigationUid(collection.uid, item.item.uid) }))
}
/>
</div>
);
}

@ -0,0 +1,233 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import IconDelete from "@material-ui/icons/Delete";
import IconCancel from "@material-ui/icons/Clear";
import IconSave from "@material-ui/icons/Save";
import * as colors from "@material-ui/core/colors";
import AppBarOverride from "../widgets/AppBarOverride";
import Container from "../widgets/Container";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import * as Etebase from "etebase";
import ColorPicker from "../widgets/ColorPicker";
import { defaultColor } from "../Pim/helpers";
import { CachedCollection } from "../Pim/helpers";
import { useCredentials } from "../credentials";
import { getCollectionManager } from "../etebase-helpers";
interface PropsType {
collection?: CachedCollection;
onSave: (collection: Etebase.Collection) => void;
onDelete: (collection: Etebase.Collection) => void;
onCancel: () => void;
}
interface FormErrors {
name?: string;
color?: string;
}
export default function CollectionEdit(props: PropsType) {
const [errors, setErrors] = React.useState<FormErrors>({});
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
const [colType, setColType] = React.useState("");
const [info, setInfo] = React.useState<Etebase.ItemMetadata>();
const [selectedColor, setSelectedColor] = React.useState("");
const etebase = useCredentials()!;
React.useEffect(() => {
if (props.collection !== undefined) {
setColType(props.collection.collectionType);
setInfo(props.collection.metadata);
if (props.collection.metadata.color) {
setSelectedColor(props.collection.metadata.color);
}
} else {
setColType("etebase.vcard");
setInfo({
name: "",
description: "",
});
}
}, [props.collection]);
if (info === undefined) {
return <React.Fragment />;
}
async function onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
const saveErrors: FormErrors = {};
const fieldRequired = "This field is required!";
const { onSave } = props;
if (!info) {
throw new Error("Got undefined info. Should never happen.");
}
const name = info.name;
const color = selectedColor;
if (!name) {
saveErrors.name = fieldRequired;
}
if (color && !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color)) {
saveErrors.color = "Must be of the form #RRGGBB or #RRGGBBAA or empty";
}
setErrors(saveErrors);
if (Object.keys(saveErrors).length > 0) {
return;
}
const colMgr = getCollectionManager(etebase);
const mtime = (new Date()).getTime();
const meta = { ...info, color, mtime };
let collection;
if (props.collection) {
collection = props.collection.collection;
collection.setMeta(meta);
} else {
collection = await colMgr.create(colType, meta, "");
}
onSave(collection);
}
function onDeleteRequest() {
setShowDeleteDialog(true);
}
const { collection, onDelete, onCancel } = props;
const item = collection?.metadata;
const pageTitle = (item !== undefined) ? item.name! : "New Collection";
const styles = {
fullWidth: {
width: "100%",
},
submit: {
marginTop: 40,
marginBottom: 20,
textAlign: "right" as any,
},
};
const colTypes = {
"etebase.vcard": "Address Book",
"etebase.vevent": "Calendar",
"etebase.vtodo": "Task List",
};
let collectionColorBox: React.ReactNode;
switch (colType) {
case "etebase.vevent":
case "etebase.vtodo":
collectionColorBox = (
<ColorPicker
defaultColor={defaultColor}
color={selectedColor}
onChange={(color: string) => setSelectedColor(color)}
error={errors.color}
/>
);
break;
}
return (
<>
<AppBarOverride title={pageTitle} />
<Container style={{ maxWidth: "30rem" }}>
<form onSubmit={onSubmit}>
<FormControl disabled={props.collection !== undefined} style={styles.fullWidth}>
<InputLabel>
Collection type
</InputLabel>
<Select
name="type"
required
value={colType}
onChange={(event: React.ChangeEvent<{ value: string }>) => setColType(event.target.value)}
>
{Object.keys(colTypes).map((x) => (
<MenuItem key={x} value={x}>{colTypes[x]}</MenuItem>
))}
</Select>
</FormControl>
<TextField
name="name"
required
label="Name of this collection"
value={info.name}
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, name: event.target.value })}
style={styles.fullWidth}
margin="normal"
error={!!errors.name}
helperText={errors.name}
/>
<TextField
name="description"
label="Description (optional)"
value={info.description}
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, description: event.target.value })}
style={styles.fullWidth}
margin="normal"
/>
{collectionColorBox}
<div style={styles.submit}>
<Button
variant="contained"
onClick={onCancel}
>
<IconCancel style={{ marginRight: 8 }} />
Cancel
</Button>
{props.collection &&
<Button
variant="contained"
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
onClick={onDeleteRequest}
>
<IconDelete style={{ marginRight: 8 }} />
Delete
</Button>
}
<Button
type="submit"
variant="contained"
color="secondary"
style={{ marginLeft: 15 }}
>
<IconSave style={{ marginRight: 8 }} />
Save
</Button>
</div>
</form>
</Container>
<ConfirmationDialog
title="Delete Confirmation"
labelOk="Delete"
open={showDeleteDialog}
onOk={() => onDelete(props.collection?.collection!)}
onCancel={() => setShowDeleteDialog(false)}
>
Are you sure you would like to delete this collection?
</ConfirmationDialog>
</>
);
}

@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import ContactsIcon from "@material-ui/icons/Contacts";
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
import { List, ListItem } from "../widgets/List";
import AppBarOverride from "../widgets/AppBarOverride";
import Container from "../widgets/Container";
import { CachedCollection, defaultColor } from "../Pim/helpers";
import ColorBox from "../widgets/ColorBox";
import ImportDialog from "./ImportDialog";
interface PropsType {
collections: CachedCollection[];
}
export default function CollectionImport(props: PropsType) {
const [selectedCollection, setSelectedCollection] = React.useState<CachedCollection>();
const collectionMap = {
"etebase.vcard": [],
"etebase.vevent": [],
"etebase.vtodo": [],
};
function colClicked(colUid: string) {
const collection = props.collections.find((x) => x.collection.uid === colUid);
setSelectedCollection(collection);
}
for (const col of props.collections) {
const colType = col.collectionType;
if (collectionMap[colType]) {
const supportsColor = (["etebase.vevent", "etebase.vtodo"].includes(colType));
const colorBox = (supportsColor) ? (
<ColorBox size={24} color={col.metadata.color || defaultColor} />
) : undefined;
collectionMap[colType].push((
<ListItem key={col.collection.uid} rightIcon={colorBox} insetChildren
onClick={() => colClicked(col.collection.uid)}>
{col.metadata.name}
</ListItem>
));
}
}
return (
<Container>
<AppBarOverride title="Import" />
<List>
<ListItem
primaryText="Address Books"
leftIcon={<ContactsIcon />}
nestedItems={collectionMap["etebase.vcard"]}
/>
<ListItem
primaryText="Calendars"
leftIcon={<CalendarTodayIcon />}
nestedItems={collectionMap["etebase.vevent"]}
/>
<ListItem
primaryText="Tasks"
leftIcon={<FormatListBulletedIcon />}
nestedItems={collectionMap["etebase.vtodo"]}
/>
</List>
{selectedCollection && (
<ImportDialog
key={(!selectedCollection).toString()}
collection={selectedCollection}
open
onClose={() => setSelectedCollection(undefined)}
/>
)}
</Container>
);
}

@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Link, useHistory } from "react-router-dom";
import IconButton from "@material-ui/core/IconButton";
import IconAdd from "@material-ui/icons/Add";
import ContactsIcon from "@material-ui/icons/Contacts";
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
import { List, ListItem } from "../widgets/List";
import AppBarOverride from "../widgets/AppBarOverride";
import Container from "../widgets/Container";
import { routeResolver } from "../App";
import { CachedCollection, defaultColor } from "../Pim/helpers";
import ColorBox from "../widgets/ColorBox";
interface PropsType {
collections: CachedCollection[];
}
export default function CollectionList(props: PropsType) {
const history = useHistory();
const collectionMap = {
"etebase.vcard": [],
"etebase.vevent": [],
"etebase.vtodo": [],
};
function colClicked(colUid: string) {
history.push(routeResolver.getRoute("collections._id", { colUid }));
}
for (const col of props.collections) {
const colType = col.collectionType;
if (collectionMap[colType]) {
const supportsColor = (["etebase.vevent", "etebase.vtodo"].includes(colType));
const colorBox = (supportsColor) ? (
<ColorBox size={24} color={col.metadata.color || defaultColor} />
) : undefined;
collectionMap[colType].push((
<ListItem key={col.collection.uid} rightIcon={colorBox} insetChildren
onClick={() => colClicked(col.collection.uid)}>
{col.metadata.name}
</ListItem>
));
}
}
return (
<Container>
<AppBarOverride title="Collections">
<IconButton
component={Link}
title="New"
{...{ to: routeResolver.getRoute("collections.new") }}
>
<IconAdd />
</IconButton>
</AppBarOverride>
<List>
<ListItem
primaryText="Address Books"
leftIcon={<ContactsIcon />}
nestedItems={collectionMap["etebase.vcard"]}
/>
<ListItem
primaryText="Calendars"
leftIcon={<CalendarTodayIcon />}
nestedItems={collectionMap["etebase.vevent"]}
/>
<ListItem
primaryText="Tasks"
leftIcon={<FormatListBulletedIcon />}
nestedItems={collectionMap["etebase.vtodo"]}
/>
</List>
</Container>
);
}

@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import TextField from "@material-ui/core/TextField";
import Checkbox from "@material-ui/core/Checkbox";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import LoadingIndicator from "../widgets/LoadingIndicator";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import PrettyFingerprint from "../widgets/PrettyFingerprint";
import { CachedCollection } from "../Pim/helpers";
import { useCredentials } from "../credentials";
interface PropsType {
collection: CachedCollection;
onOk: (username: string, publicKey: Uint8Array, accessLevel: Etebase.CollectionAccessLevel) => void;
onClose: () => void;
}
export default function CollectionMemberAddDialog(props: PropsType) {
const etebase = useCredentials()!;
const [addUser, setAddUser] = React.useState("");
const [publicKey, setPublicKey] = React.useState<Uint8Array>();
const [readOnly, setReadOnly] = React.useState(false);
const [userChosen, setUserChosen] = React.useState(false);
const [error, setError] = React.useState<Error>();
async function onAddRequest(_user: string) {
setUserChosen(true);
const inviteMgr = etebase.getInvitationManager();
try {
const userProfile = await inviteMgr.fetchUserProfile(addUser);
setPublicKey(userProfile.pubkey);
} catch (e) {
setError(e);
}
}
function onOk() {
props.onOk(addUser, publicKey!, readOnly ? Etebase.CollectionAccessLevel.ReadOnly : Etebase.CollectionAccessLevel.ReadWrite);
}
const { onClose } = props;
if (error) {
return (
<>
<ConfirmationDialog
title="Error adding member"
labelOk="OK"
open
onOk={onClose}
onCancel={onClose}
>
User ({addUser}) not found.
</ConfirmationDialog>
</>
);
}
if (publicKey) {
return (
<>
<ConfirmationDialog
title="Verify security fingerprint"
labelOk="OK"
open
onOk={onOk}
onCancel={onClose}
>
<p>
Verify {addUser}'s security fingerprint to ensure the encryption is secure.
</p>
<div style={{ textAlign: "center" }}>
<PrettyFingerprint publicKey={publicKey} />
</div>
</ConfirmationDialog>
</>
);
} else {
return (
<>
<ConfirmationDialog
title="Invite user"
labelOk="OK"
open={!userChosen}
onOk={onAddRequest}
onCancel={onClose}
>
{userChosen ?
<LoadingIndicator />
:
<>
<TextField
name="addUser"
placeholder="Username"
style={{ width: "100%" }}
value={addUser}
onChange={(ev) => setAddUser(ev.target.value)}
/>
<FormControlLabel
control={
<Checkbox
checked={readOnly}
onChange={(event) => setReadOnly(event.target.checked)}
/>
}
label="Read only?"
/>
</>
}
</ConfirmationDialog>
</>
);
}
}

@ -0,0 +1,157 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import { List, ListItem } from "../widgets/List";
import IconMemberAdd from "@material-ui/icons/PersonAdd";
import VisibilityIcon from "@material-ui/icons/Visibility";
import AdminIcon from "@material-ui/icons/SupervisedUserCircle";
import AppBarOverride from "../widgets/AppBarOverride";
import Container from "../widgets/Container";
import LoadingIndicator from "../widgets/LoadingIndicator";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import { useCredentials } from "../credentials";
import { getCollectionManager } from "../etebase-helpers";
import { CachedCollection } from "../Pim/helpers";
import CollectionMemberAddDialog from "./CollectionMemberAddDialog";
import Alert from "@material-ui/lab/Alert";
import { pushMessage } from "../store/actions";
import { useDispatch } from "react-redux";
interface PropsType {
collection: CachedCollection;
}
export default function CollectionMembers(props: PropsType) {
const etebase = useCredentials()!;
const dispatch = useDispatch();
const [members, setMembers] = React.useState<Etebase.CollectionMember[]>();
const [revokeUser, setRevokeUser] = React.useState<Etebase.CollectionMember | null>(null);
const [addMemberOpen, setAddMemberOpen] = React.useState(false);
const [error, setError] = React.useState<Error>();
const { collection, metadata } = props.collection;
const revokeUserIsAdmin = revokeUser?.accessLevel === Etebase.CollectionAccessLevel.Admin;
async function fetchMembers() {
const colMgr = getCollectionManager(etebase);
const memberManager = colMgr.getMemberManager(collection);
try {
const ret: Etebase.CollectionMember[] = [];
let iterator: string | null = null;
let done = false;
while (!done) {
const members = await memberManager.list({ iterator, limit: 30 });
iterator = members.iterator as string;
done = members.done;
for (const member of members.data) {
ret.push(member);
}
}
setMembers(ret);
} catch (e) {
setError(e);
}
}
React.useEffect(() => {
fetchMembers();
}, []);
async function onRevokeDo() {
const colMgr = getCollectionManager(etebase);
const memberManager = colMgr.getMemberManager(collection);
await memberManager.remove(revokeUser!.username);
await fetchMembers();
setRevokeUser(null);
dispatch(pushMessage({ message: "Removed member", severity: "success" }));
}
async function onMemberAdd(username: string, pubkey: Uint8Array, accessLevel: Etebase.CollectionAccessLevel) {
const inviteMgr = etebase.getInvitationManager();
await inviteMgr.invite(collection, username, pubkey, accessLevel);
await fetchMembers();
setAddMemberOpen(false);
dispatch(pushMessage({ message: "Invitation sent", severity: "success" }));
}
return (
<>
<AppBarOverride title={`${metadata.name} - Members`} />
<Container style={{ maxWidth: "30rem" }}>
{error && (
<Alert color="error">
{error.toString()}
</Alert>
)}
{members ?
<List>
<ListItem rightIcon={<IconMemberAdd />} onClick={() => setAddMemberOpen(true)}>
Invite user
</ListItem>
{(members.length > 0 ?
members.map((member) => {
let rightIcon: React.ReactElement | undefined = undefined;
if (member.accessLevel === Etebase.CollectionAccessLevel.ReadOnly) {
rightIcon = (<div title="Read Only"><VisibilityIcon /></div>);
} else if (member.accessLevel === Etebase.CollectionAccessLevel.Admin) {
rightIcon = (<div title="Admin"><AdminIcon /></div>);
}
return (
<ListItem
key={member.username}
onClick={() => setRevokeUser(member)}
rightIcon={rightIcon}
>
{member.username}
</ListItem>
);
})
:
<ListItem>
No members
</ListItem>
)}
</List>
:
<LoadingIndicator />
}
</Container>
<ConfirmationDialog
title="Remove member"
labelOk="OK"
open={revokeUser !== null}
onOk={(revokeUserIsAdmin) ? () => setRevokeUser(null) : onRevokeDo}
onCancel={() => setRevokeUser(null)}
>
{(revokeUserIsAdmin) ? (
<p>
Revoking admin access is not allowed.
</p>
) : (
<p>
Would you like to revoke {revokeUser?.username}'s access?<br />
Please be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information.
</p>
)}
</ConfirmationDialog>
{addMemberOpen &&
<CollectionMemberAddDialog
collection={props.collection}
onOk={onMemberAdd}
onClose={() => setAddMemberOpen(false)}
/>
}
</>
);
}

@ -0,0 +1,215 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
import Dropzone from "react-dropzone";
import LoadingIndicator from "../widgets/LoadingIndicator";
import { arrayToChunkIterator } from "../helpers";
import * as uuid from "uuid";
import * as ICAL from "ical.js";
import { ContactType, EventType, TaskType, PimType } from "../pim-types";
import { useCredentials } from "../credentials";
import { CachedCollection } from "../Pim/helpers";
import { getCollectionManager } from "../etebase-helpers";
import { useAsyncDispatch } from "../store";
import { itemBatch } from "../store/actions";
const CHUNK_SIZE = 40;
interface PropsType {
collection: CachedCollection;
open: boolean;
onClose?: () => void;
}
export default function ImportDialog(props: PropsType) {
const etebase = useCredentials()!;
const dispatch = useAsyncDispatch();
const [loading, setLoading] = React.useState(false);
const [itemsProcessed, setItemsProccessed] = React.useState<number>();
function onFileDropCommon(itemsCreator: (fileText: string) => PimType[], acceptedFiles: File[], rejectedFiles: File[]) {
// XXX: implement handling of rejectedFiles
const reader = new FileReader();
reader.onabort = () => {
setLoading(false);
console.error("Import Aborted");
alert("file reading was aborted");
};
reader.onerror = (e) => {
setLoading(false);
console.error(e);
alert("file reading has failed");
};
reader.onload = async () => {
try {
const fileText = reader.result as string;
const items = itemsCreator(fileText);
const { collection } = props.collection;
const colMgr = getCollectionManager(etebase);
const itemMgr = colMgr.getItemManager(collection);
const eteItems = [];
for (const item of items) {
const mtime = (new Date()).getTime();
const meta = {
mtime,
name: item.uid,
};
const content = item.toIcal();
const eteItem = await itemMgr.create(meta, content);
eteItems.push(eteItem);
}
const chunks = arrayToChunkIterator(eteItems, CHUNK_SIZE);
for (const chunk of chunks) {
await dispatch(itemBatch(collection, itemMgr, chunk));
}
setItemsProccessed(items.length);
} catch (e) {
console.error(e);
alert("An error has occurred, please contact developers.");
throw e;
} finally {
if (props.onClose) {
setLoading(false);
}
}
};
if (acceptedFiles.length > 0) {
setLoading(true);
acceptedFiles.forEach((file) => {
reader.readAsText(file);
});
} else {
alert("Failed importing file. Is the file type supported?");
console.log("Failed importing files. Rejected:", rejectedFiles);
}
}
function onFileDropContact(acceptedFiles: File[], rejectedFiles: File[]) {
const itemsCreator = (fileText: string) => {
const mainComp = ICAL.parse(fileText);
return mainComp.map((comp) => {
const ret = new ContactType(new ICAL.Component(comp));
if (!ret.uid) {
ret.uid = uuid.v4();
}
return ret;
});
};
onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles);
}
function onFileDropEvent(acceptedFiles: File[], rejectedFiles: File[]) {
const itemsCreator = (fileText: string) => {
const calendarComp = new ICAL.Component(ICAL.parse(fileText));
return calendarComp.getAllSubcomponents("vevent").map((comp) => {
const ret = new EventType(comp);
if (!ret.uid) {
ret.uid = uuid.v4();
}
return ret;
});
};
onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles);
}
function onFileDropTask(acceptedFiles: File[], rejectedFiles: File[]) {
const itemsCreator = (fileText: string) => {
const calendarComp = new ICAL.Component(ICAL.parse(fileText));
return calendarComp.getAllSubcomponents("vtodo").map((comp) => {
const ret = new TaskType(comp);
if (!ret.uid) {
ret.uid = uuid.v4();
}
return ret;
});
};
onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles);
}
function onClose() {
if (loading) {
return;
}
if (props.onClose) {
props.onClose();
}
}
const { collectionType } = props.collection;
let acceptTypes;
let dropFunction;
if (collectionType === "etebase.vcard") {
acceptTypes = ["text/vcard", "text/directory", "text/x-vcard", ".vcf"];
dropFunction = onFileDropContact;
} else if (collectionType === "etebase.vevent") {
acceptTypes = ["text/calendar", ".ics", ".ical"];
dropFunction = onFileDropEvent;
} else if (collectionType === "etebase.vtodo") {
acceptTypes = ["text/calendar", ".ics", ".ical"];
dropFunction = onFileDropTask;
}
return (
<React.Fragment>
<Dialog
open={props.open}
onClose={onClose}
>
<DialogTitle>Import entries from file?</DialogTitle>
<DialogContent>
{(itemsProcessed !== undefined) ? (
<p>Imported {itemsProcessed} items.</p>
) : (loading ?
<LoadingIndicator style={{ display: "block", margin: "auto" }} />
:
<Dropzone
onDrop={dropFunction}
multiple={false}
accept={acceptTypes}
>
{({ getRootProps, getInputProps }) => (
<section>
<div {...getRootProps()}>
<input {...getInputProps()} />
<DialogContentText id="alert-dialog-description">
To import entries from a file, drag 'n' drop it here, or click to open the file selector.
</DialogContentText>
</div>
</section>
)}
</Dropzone>
)}
</DialogContent>
<DialogActions>
<Button disabled={loading} onClick={onClose} color="primary">
Close
</Button>
</DialogActions>
</Dialog>
</React.Fragment>
);
}

@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Switch, Route, Redirect } from "react-router";
import * as Etebase from "etebase";
import { useCredentials } from "../credentials";
import { routeResolver } from "../App";
import LoadingIndicator from "../widgets/LoadingIndicator";
import AppBarOverride from "../widgets/AppBarOverride";
import { List, ListItem } from "../widgets/List";
import Container from "../widgets/Container";
import { IconButton } from "@material-ui/core";
import IconAccept from "@material-ui/icons/Done";
import IconReject from "@material-ui/icons/Close";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import PrettyFingerprint from "../widgets/PrettyFingerprint";
async function loadInvitations(etebase: Etebase.Account) {
const ret: Etebase.SignedInvitation[] = [];
const invitationManager = etebase.getInvitationManager();
let iterator: string | null = null;
let done = false;
while (!done) {
const invitations = await invitationManager.listIncoming({ iterator, limit: 30 });
iterator = invitations.iterator as string;
done = invitations.done;
ret.push(...invitations.data);
}
return ret;
}
export default function Invitations() {
return (
<Switch>
<Route
path={routeResolver.getRoute("collections.invitations")}
exact
>
<Redirect to={routeResolver.getRoute("collections.invitations.incoming")} />
</Route>
<Route
path={routeResolver.getRoute("collections.invitations.incoming")}
exact
>
<InvitationsIncoming
/>
</Route>
</Switch>
);
}
function InvitationsIncoming() {
const [invitations, setInvitations] = React.useState<Etebase.SignedInvitation[]>();
const [chosenInvitation, setChosenInvitation] = React.useState<Etebase.SignedInvitation>();
const etebase = useCredentials()!;
React.useEffect(() => {
loadInvitations(etebase).then(setInvitations);
}, [etebase]);
function removeInvitation(invite: Etebase.SignedInvitation) {
setInvitations(invitations?.filter((x) => x.uid !== invite.uid));
}
async function reject(invite: Etebase.SignedInvitation) {
const invitationManager = etebase.getInvitationManager();
await invitationManager.reject(invite);
removeInvitation(invite);
}
async function accept(invite: Etebase.SignedInvitation) {
const invitationManager = etebase.getInvitationManager();
await invitationManager.accept(invite);
setChosenInvitation(undefined);
removeInvitation(invite);
}
return (
<>
<AppBarOverride title="Incoming Invitations" />
<Container style={{ maxWidth: "30rem" }}>
{invitations ?
<List>
{(invitations.length > 0 ?
invitations.map((invite) => (
<ListItem
key={invite.uid}
rightIcon={(
<>
<IconButton title="Reject" onClick={() => reject(invite)}>
<IconReject color="error" />
</IconButton>
<IconButton title="Accept" onClick={() => setChosenInvitation(invite)}>
<IconAccept color="secondary" />
</IconButton>
</>
)}
>
Invitation from {invite.fromUsername}
</ListItem>
))
:
<ListItem>
No invitations
</ListItem>
)}
</List>
:
<LoadingIndicator />
}
</Container>
{chosenInvitation && (
<ConfirmationDialog
title="Accept invitation"
labelOk="OK"
open={!!chosenInvitation}
onOk={() => accept(chosenInvitation)}
onCancel={() => setChosenInvitation(undefined)}
>
Please verify the inviter's security fingerprint to ensure the invitation is secure:
<div style={{ textAlign: "center" }}>
<PrettyFingerprint publicKey={chosenInvitation.fromPubkey} />
</div>
</ConfirmationDialog>
)}
</>
);
}

@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Switch, Route, useHistory } from "react-router";
import * as Etebase from "etebase";
import { useCredentials } from "../credentials";
import { useCollections, getCollectionManager } from "../etebase-helpers";
import { routeResolver } from "../App";
import LoadingIndicator from "../widgets/LoadingIndicator";
import { CachedCollection, getDecryptCollectionsFunction, PimFab } from "../Pim/helpers";
import CollectionList from "./CollectionList";
import CollectionImport from "./CollectionImport";
import PageNotFound from "../PageNotFound";
import CollectionEdit from "./CollectionEdit";
import CollectionMembers from "./CollectionMembers";
import Collection from "./Collection";
import { useAsyncDispatch } from "../store";
import { collectionUpload, pushMessage } from "../store/actions";
import Invitations from "./Invitations";
const decryptCollections = getDecryptCollectionsFunction();
export default function CollectionsMain() {
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
const history = useHistory();
const etebase = useCredentials()!;
const collections = useCollections(etebase);
const dispatch = useAsyncDispatch();
React.useEffect(() => {
if (collections) {
decryptCollections(collections)
.then((entries) => setCachedCollections(entries));
// FIXME: handle failure to decrypt collections
}
}, [collections]);
if (!cachedCollections) {
return (
<LoadingIndicator />
);
}
async function onSave(collection: Etebase.Collection): Promise<void> {
const colMgr = getCollectionManager(etebase);
await dispatch(collectionUpload(colMgr, collection));
dispatch(pushMessage({ message: "Collection saved", severity: "success" }));
history.push(routeResolver.getRoute("collections"));
}
async function onDelete(collection: Etebase.Collection) {
const colMgr = getCollectionManager(etebase);
const mtime = (new Date()).getTime();
const meta = collection.getMeta();
collection.setMeta({ ...meta, mtime });
collection.delete(true);
await dispatch(collectionUpload(colMgr, collection));
dispatch(pushMessage({ message: "Collection deleted", severity: "success" }));
history.push(routeResolver.getRoute("collections"));
}
function onCancel() {
history.goBack();
}
return (
<Switch>
<Route
path={routeResolver.getRoute("collections")}
exact
>
<CollectionList
collections={cachedCollections}
/>
<PimFab
onClick={() => history.push(
routeResolver.getRoute("collections.new")
)}
/>
</Route>
<Route
path={routeResolver.getRoute("collections.import")}
exact
>
<CollectionImport
collections={cachedCollections}
/>
</Route>
<Route
path={routeResolver.getRoute("collections.new")}
exact
>
<CollectionEdit
onSave={onSave}
onDelete={onDelete}
onCancel={onCancel}
/>
</Route>
<Route
path={routeResolver.getRoute("collections.invitations")}
>
<Invitations />
</Route>
<Route
path={routeResolver.getRoute("collections._id")}
render={({ match }) => {
const colUid = match.params.colUid;
const collection = cachedCollections.find((x) => x.collection.uid === colUid);
if (!collection) {
return (<PageNotFound />);
}
return (
<Switch>
<Route
path={routeResolver.getRoute("collections._id.edit")}
exact
>
<CollectionEdit
collection={collection}
onSave={onSave}
onDelete={onDelete}
onCancel={onCancel}
/>
</Route>
<Route
path={routeResolver.getRoute("collections._id.members")}
exact
>
<CollectionMembers collection={collection} />
</Route>
<Route
path={routeResolver.getRoute("collections._id")}
exact
>
<Collection collection={collection} />
</Route>
</Switch>
);
}}
/>
</Switch>
);
}

@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { createSelector } from "reselect";
import * as colors from "@material-ui/core/colors";
import { AutoSizer, List as VirtualizedList } from "react-virtualized";
import { Avatar } from "../widgets/Avatar";
import { List, ListItem } from "../widgets/List";
import { ContactType } from "../pim-types";
function getContactColor(contact: ContactType) {
const colorOptions = [
colors.red[500],
colors.pink[500],
colors.purple[500],
colors.deepPurple[500],
colors.indigo[500],
colors.blue[500],
colors.lightBlue[500],
colors.cyan[500],
colors.teal[500],
colors.green[500],
colors.lightGreen[500],
colors.lime[500],
colors.yellow[500],
colors.amber[500],
colors.orange[500],
colors.deepOrange[500],
];
if (!contact.uid) {
console.error(`Contact uid is null for contact ${contact.fn}`);
console.error(contact.toIcal());
return colorOptions[0];
}
let sum = 0;
const uid = contact.uid;
for (let i = 0 ; i < uid.length ; i++) {
sum += uid.charCodeAt(i);
}
return colorOptions[sum % colorOptions.length];
}
const AddressBookItem = React.memo((_props: any) => {
const {
style,
entry,
onClick,
} = _props;
const name = entry.fn;
return (
<ListItem
style={style}
leftIcon={
<Avatar style={{ backgroundColor: getContactColor(entry) }}>
{name && name[0] && name[0].toUpperCase()}
</Avatar>}
primaryText={name}
onClick={() => onClick(entry)}
/>
);
});
const sortSelector = createSelector(
(entries: ContactType[]) => entries,
(entries) => {
return entries.sort((_a, _b) => {
const a = _a.fn ?? "";
const b = _b.fn ?? "";
return a.localeCompare(b, undefined, { sensitivity: "base" });
});
}
);
interface PropsType {
entries: ContactType[];
onItemClick: (contact: ContactType) => void;
filter?: (a: ContactType) => boolean;
}
class AddressBook extends React.PureComponent<PropsType> {
public render() {
const sortedEntries = sortSelector(this.props.entries);
const entries = (this.props.filter) ?
sortedEntries.filter(this.props.filter)
: sortedEntries;
return (
<List style={{ height: "calc(100vh - 350px)" }}>
<AutoSizer>
{({ height, width }) => (
<VirtualizedList
width={width}
height={height}
rowCount={entries.length}
rowHeight={56}
rowRenderer={({ index, key, style }) => {
return (
<AddressBookItem
key={key}
style={style}
entry={entries[index]}
onClick={this.props.onItemClick}
/>
);
}}
/>
)}
</AutoSizer>
</List>
);
}
}
export default AddressBook;

@ -0,0 +1,203 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import moment from "moment";
import { List, ListItem, ListDivider as Divider } from "../widgets/List";
import IconHome from "@material-ui/icons/Home";
import IconDate from "@material-ui/icons/DateRange";
import CommunicationCall from "@material-ui/icons/Call";
import CommunicationChatBubble from "@material-ui/icons/ChatBubble";
import CommunicationEmail from "@material-ui/icons/Email";
import CopyIcon from "../icons/Copy";
import PimItemHeader from "../components/PimItemHeader";
import { ContactType } from "../pim-types";
import { IconButton, Avatar } from "@material-ui/core";
class Contact extends React.PureComponent {
public props: {
item?: ContactType;
};
public render() {
if (this.props.item === undefined) {
throw Error("Contact should be defined!");
}
const contact = this.props.item;
const name = contact.fn;
const revProp = contact.comp.getFirstProperty("rev");
const lastModified = (revProp) ?
"Modified: " + moment(revProp.getFirstValue().toJSDate()).format("LLLL") : undefined;
const lists = [];
function getAllType(
propName: string,
props_: any,
valueToHref?: (value: string, type: string) => string,
primaryTransform?: (value: string, type: string) => string,
secondaryTransform?: (value: string, type: string) => string) {
return contact.comp.getAllProperties(propName).map((prop, idx) => {
const type = prop.toJSON()[1].type;
const values = prop.getValues().map((val) => {
const primaryText = primaryTransform ? primaryTransform(val, type) : val;
const clipboardButton = (
<IconButton
onClick={(e) => {
e.preventDefault();
(window as any).navigator.clipboard.writeText(primaryText);
}}
>
<CopyIcon />
</IconButton>
);
const { ref, ...props } = props_;
return (
<ListItem
key={idx}
href={valueToHref ? valueToHref(val, type) : undefined}
primaryText={primaryText}
rightIcon={clipboardButton}
secondaryText={secondaryTransform ? secondaryTransform(val, type) : type}
{...props}
/>
);
});
return values;
});
}
lists.push(getAllType(
"tel",
{
leftIcon: <CommunicationCall />,
},
(x) => ("tel:" + x)
));
lists.push(getAllType(
"email",
{
leftIcon: <CommunicationEmail />,
},
(x) => ("mailto:" + x)
));
lists.push(getAllType(
"impp",
{
leftIcon: <CommunicationChatBubble />,
},
(x) => x,
(x) => (x.substring(x.indexOf(":") + 1)),
(x) => (x.substring(0, x.indexOf(":")))
));
lists.push(getAllType(
"adr",
{
leftIcon: <IconHome />,
}
));
lists.push(getAllType(
"bday",
{
leftIcon: <IconDate />,
},
undefined,
((x: any) => moment(x.toJSDate()).format("dddd, LL")),
() => "Birthday"
));
lists.push(getAllType(
"anniversary",
{
leftIcon: <IconDate />,
},
undefined,
((x: any) => moment(x.toJSDate()).format("dddd, LL")),
() => "Anniversary"
));
const skips = ["tel", "email", "impp", "adr", "bday", "anniversary", "rev",
"prodid", "uid", "fn", "n", "version", "photo", "note"];
const theRest = contact.comp.getAllProperties().filter((prop) => (
skips.indexOf(prop.name) === -1
)).map((prop, idx) => {
const values = prop.getValues().map((_val) => {
const val = (_val instanceof String) ? _val : _val.toString();
return (
<ListItem
key={idx}
insetChildren
primaryText={val}
secondaryText={prop.name}
/>
);
});
return values;
});
{
const note = contact.comp.getFirstPropertyValue("note");
const item = (
<ListItem
key="note"
insetChildren
secondaryText="note"
>
<pre style={{ wordWrap: "break-word", whiteSpace: "pre-wrap", overflowX: "auto" }}>{note}</pre>
</ListItem>
);
theRest.push([item]);
}
function listIfNotEmpty(items: JSX.Element[][]) {
if (items.length > 0) {
return (
<React.Fragment>
<List>
{items}
</List>
<List>
<Divider inset />
</List>
</React.Fragment>
);
} else {
return undefined;
}
}
const contactImageSrc = contact.comp.getFirstProperty("photo")?.getFirstValue();
return (
<div>
<PimItemHeader text={name} rightItem={contactImageSrc && (<Avatar style={{ width: "3em", height: "3em" }} src={contactImageSrc} />)}>
{lastModified && (
<span style={{ fontSize: "90%" }}>{lastModified}</span>
)}
</PimItemHeader>
{lists.map((list, idx) => (
<React.Fragment key={idx}>
{listIfNotEmpty(list)}
</React.Fragment>
))}
<List>
{theRest}
</List>
</div>
);
}
}
export default Contact;

@ -0,0 +1,710 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import IconButton from "@material-ui/core/IconButton";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import * as colors from "@material-ui/core/colors";
import IconDelete from "@material-ui/icons/Delete";
import IconAdd from "@material-ui/icons/Add";
import IconClear from "@material-ui/icons/Clear";
import IconCancel from "@material-ui/icons/Clear";
import IconSave from "@material-ui/icons/Save";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import { CachedCollection } from "../Pim/helpers";
import * as uuid from "uuid";
import * as ICAL from "ical.js";
import { ContactType } from "../pim-types";
import { History } from "history";
import Autocomplete from "@material-ui/lab/Autocomplete";
const telTypes = [
{ type: "Home" },
{ type: "Work" },
{ type: "Cell" },
{ type: "Other" },
];
const emailTypes = telTypes;
const addressTypes = [
{ type: "Home" },
{ type: "Work" },
{ type: "Other" },
];
const imppTypes = [
{ type: "Jabber" },
{ type: "Hangouts" },
{ type: "Other" },
];
const TypeSelector = (props: any) => {
const types = props.types as Array<{ type: string }>;
return (
<Select
style={props.style}
value={props.value}
onChange={props.onChange}
>
{types.map((x) => (
<MenuItem key={x.type} value={x.type.toLowerCase()}>{x.type}</MenuItem>
))}
</Select>
);
};
class ValueType {
public type: string;
public value: string;
constructor(type?: string, value?: string) {
this.type = type ? type : "home";
this.value = value ? value : "";
}
}
interface ValueTypeComponentProps {
type?: string;
style?: object;
multiline?: boolean;
types: Array<{ type: string }>;
name: string;
placeholder: string;
value: ValueType;
onClearRequest: (name: string) => void;
onChange: (name: string, type: string, value: string) => void;
}
const ValueTypeComponent = (props: ValueTypeComponentProps) => {
return (
<React.Fragment>
<TextField
type={props.type}
placeholder={props.placeholder}
multiline={props.multiline}
style={props.style}
value={props.value.value}
onChange={(event: React.ChangeEvent<any>) => props.onChange(props.name, props.value.type, event.target.value)}
/>
<IconButton
onClick={() => props.onClearRequest(props.name)}
title="Remove"
>
<IconClear />
</IconButton>
<TypeSelector
value={props.value.type}
types={props.types}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) => (
props.onChange(props.name, event.target.value, props.value.value)
)}
/>
</React.Fragment>
);
};
interface PropsType {
collections: CachedCollection[];
initialCollection?: string;
item?: ContactType;
onSave: (contact: ContactType, collectionUid: string, originalContact?: ContactType) => Promise<void>;
onDelete: (contact: ContactType, collectionUid: string) => void;
onCancel: () => void;
history: History<any>;
allGroups: ContactType[];
}
class ContactEdit extends React.PureComponent<PropsType> {
public state: {
uid: string;
fn: string;
lastName: string;
firstName: string;
middleName: string;
namePrefix: string;
nameSuffix: string;
phone: ValueType[];
email: ValueType[];
address: ValueType[];
impp: ValueType[];
org: string;
note: string;
title: string;
collectionUid: string;
showDeleteDialog: boolean;
collectionGroups: {};
newGroups: string[];
originalGroups: {};
};
constructor(props: PropsType) {
super(props);
this.state = {
uid: "",
fn: "",
lastName: "",
firstName: "",
middleName: "",
namePrefix: "",
nameSuffix: "",
phone: [new ValueType()],
email: [new ValueType()],
address: [new ValueType()],
impp: [new ValueType("jabber")],
org: "",
note: "",
title: "",
collectionUid: "",
showDeleteDialog: false,
collectionGroups: {},
newGroups: [],
originalGroups: [],
};
if (this.props.item !== undefined) {
const contact = this.props.item;
this.state.uid = contact.uid;
this.state.fn = contact.fn ? contact.fn : "";
if (contact.n) {
this.state.lastName = contact.n[0];
this.state.firstName = contact.n[1];
this.state.middleName = contact.n[2];
this.state.namePrefix = contact.n[3];
this.state.nameSuffix = contact.n[4];
} else {
let name = this.state.fn.trim().split(",");
if (name.length > 2 && name[0] !== "" && name[name.length - 1] !== "") {
this.state.nameSuffix = name.pop() || "";
}
name = name.join(",").split(" ");
if (name.length === 1) {
this.state.firstName = name[0];
} else if (name.length === 2) {
this.state.firstName = name[0];
this.state.lastName = name[1];
} else if (name.length > 2) {
this.state.firstName = name.slice(0, name.length - 2).join(" ");
this.state.middleName = name[name.length - 2];
this.state.lastName = name[name.length - 1];
}
}
// FIXME: Am I really getting all the values this way?
const propToValueType = (comp: ICAL.Component, propName: string) => (
comp.getAllProperties(propName).map((prop) => (
new ValueType(
prop.toJSON()[1].type,
prop.getFirstValue()
)
))
);
this.state.phone = propToValueType(contact.comp, "tel");
this.state.email = propToValueType(contact.comp, "email");
this.state.address = propToValueType(contact.comp, "adr");
this.state.impp = propToValueType(contact.comp, "impp");
const propToStringType = (comp: ICAL.Component, propName: string) => {
const val = comp.getFirstPropertyValue(propName);
return val ? val : "";
};
this.state.org = propToStringType(contact.comp, "org");
this.state.title = propToStringType(contact.comp, "title");
this.state.note = propToStringType(contact.comp, "note");
} else {
this.state.uid = uuid.v4();
}
if (props.initialCollection) {
this.state.collectionUid = props.initialCollection;
} else if (props.collections[0]) {
this.state.collectionUid = props.collections[0].collection.uid;
}
this.state.collectionGroups = this.getCollectionGroups(this.state.collectionUid);
Object.values(this.state.collectionGroups).forEach((group: ContactType) => {
if (group.members.includes(this.state.uid)) {
this.state.newGroups.push(group.fn);
this.state.originalGroups[group.fn] = undefined;
}
});
this.onSubmit = this.onSubmit.bind(this);
this.addMetadata = this.addMetadata.bind(this);
this.getCollectionGroups = this.getCollectionGroups.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleCollectionChange = this.handleCollectionChange.bind(this);
this.reloadGroupSuggestions = this.reloadGroupSuggestions.bind(this);
this.handleValueTypeChange = this.handleValueTypeChange.bind(this);
this.addValueType = this.addValueType.bind(this);
this.removeValueType = this.removeValueType.bind(this);
this.onDeleteRequest = this.onDeleteRequest.bind(this);
}
public addValueType(name: string, _type?: string) {
const type = _type ? _type : "home";
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray.push(new ValueType(type));
return {
...prevState,
[name]: newArray,
};
});
}
public removeValueType(name: string, idx: number) {
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray.splice(idx, 1);
return {
...prevState,
[name]: newArray,
};
});
}
public handleValueTypeChange(name: string, idx: number, value: ValueType) {
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray[idx] = value;
return {
...prevState,
[name]: newArray,
};
});
}
public handleChange(name: string, value: string | string[]) {
this.setState({
[name]: value,
});
}
public getCollectionGroups(collectionUid: string) {
const groups = {};
this.props.allGroups.forEach((group) => {
if (collectionUid === group.collectionUid) {
groups[group.fn] = group;
}
});
return groups;
}
public reloadGroupSuggestions(collectionUid: string) {
this.setState({
collectionGroups: this.getCollectionGroups(collectionUid),
newGroups: [],
});
}
public handleCollectionChange(contact: any) {
const name = contact.target.name;
const value = contact.target.value;
this.reloadGroupSuggestions(value);
this.handleChange(name, value);
}
public handleInputChange(contact: any) {
const name = contact.target.name;
const value = contact.target.value;
this.handleChange(name, value);
}
public addMetadata(item: ContactType, uid: string, isGroup: boolean) {
const comp = item.comp;
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
comp.updatePropertyWithValue("version", "4.0");
comp.updatePropertyWithValue("uid", uid);
comp.updatePropertyWithValue("rev", ICAL.Time.now());
if (isGroup) {
comp.updatePropertyWithValue("kind", "group");
}
}
public onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
const contact = (this.props.item) ?
this.props.item.clone()
:
new ContactType(new ICAL.Component(["vcard", [], []]))
;
const comp = contact.comp;
this.addMetadata(contact, this.state.uid, false);
// Add new groups
this.state.newGroups.forEach((group) => {
if (!this.state.collectionGroups[group]) {
const newGroup = new ContactType(new ICAL.Component(["vcard", [], []]));
this.addMetadata(newGroup, uuid.v4(), true);
newGroup.comp.updatePropertyWithValue("fn", group.trim());
newGroup.comp.updatePropertyWithValue("member", `urn:uuid:${this.state.uid}`);
this.props.onSave(newGroup, this.state.collectionUid, undefined);
} else if (!(group in this.state.originalGroups)) {
const oldGroup = this.state.collectionGroups[group];
const updatedGroup = oldGroup.clone();
updatedGroup.comp.addPropertyWithValue("member", `urn:uuid:${this.state.uid}`);
this.props.onSave(updatedGroup, this.state.collectionUid, oldGroup);
}
});
// Remove deleted groups
Object.keys(this.state.originalGroups).filter((x) => !this.state.newGroups.includes(x)).forEach((removed) => {
const deletedGroup = this.state.collectionGroups[removed];
const updatedGroup = deletedGroup.clone();
const members = updatedGroup.members.filter((uid: string) => uid !== this.state.uid);
updatedGroup.comp.removeAllProperties("member");
members.forEach((m: string) => updatedGroup.comp.addPropertyWithValue("member", `urn:uuid:${m}`));
this.props.onSave(updatedGroup, this.state.collectionUid, deletedGroup);
});
const lastName = this.state.lastName.trim();
const firstName = this.state.firstName.trim();
const middleName = this.state.middleName.trim();
const namePrefix = this.state.namePrefix.trim();
const nameSuffix = this.state.nameSuffix.trim();
let fn = `${namePrefix} ${firstName} ${middleName} ${lastName}`.trim();
if (fn === "") {
fn = nameSuffix;
} else if (nameSuffix !== "") {
fn = `${fn}, ${nameSuffix}`;
}
comp.updatePropertyWithValue("fn", fn);
const name = [lastName,
firstName,
middleName,
namePrefix,
nameSuffix,
];
comp.updatePropertyWithValue("n", name);
function setProperties(name: string, source: ValueType[]) {
comp.removeAllProperties(name);
source.forEach((x) => {
if (x.value === "") {
return;
}
const prop = new ICAL.Property(name, comp);
prop.setParameter("type", x.type);
prop.setValue(x.value);
comp.addProperty(prop);
});
}
setProperties("tel", this.state.phone);
setProperties("email", this.state.email);
setProperties("adr", this.state.address);
setProperties("impp", this.state.impp.map((x) => (
{ type: x.type, value: x.type + ":" + x.value }
)));
function setProperty(name: string, value: string) {
comp.removeAllProperties(name);
if (value !== "") {
comp.updatePropertyWithValue(name, value);
}
}
setProperty("org", this.state.org);
setProperty("title", this.state.title);
setProperty("note", this.state.note);
this.props.onSave(contact, this.state.collectionUid, this.props.item)
.then(() => {
this.props.history.goBack();
});
}
public onDeleteRequest() {
this.setState({
showDeleteDialog: true,
});
}
public render() {
const styles = {
form: {
},
fullWidth: {
width: "100%",
boxSizing: "border-box" as any,
},
submit: {
marginTop: 40,
marginBottom: 20,
textAlign: "right" as any,
},
};
return (
<React.Fragment>
<h2>
{this.props.item ? "Edit Contact" : "New Contact"}
</h2>
<form style={styles.form} onSubmit={this.onSubmit}>
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
<InputLabel>
Saving to
</InputLabel>
<Select
name="collectionUid"
value={this.state.collectionUid}
onChange={this.handleCollectionChange}
>
{this.props.collections.map((x) => (
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
))}
</Select>
</FormControl>
<TextField
name="namePrefix"
placeholder="Prefix"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.namePrefix}
onChange={this.handleInputChange}
/>
<TextField
name="firstName"
placeholder="First name"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.firstName}
onChange={this.handleInputChange}
/>
<TextField
name="middleName"
placeholder="Middle name"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.middleName}
onChange={this.handleInputChange}
/>
<TextField
name="lastName"
placeholder="Last name"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.lastName}
onChange={this.handleInputChange}
/>
<TextField
name="nameSuffix"
placeholder="Suffix"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.nameSuffix}
onChange={this.handleInputChange}
/>
<div>
Phone numbers
<IconButton
onClick={() => this.addValueType("phone")}
title="Add phone number"
>
<IconAdd />
</IconButton>
</div>
{this.state.phone.map((x, idx) => (
<ValueTypeComponent
key={idx}
name="phone"
placeholder="Phone"
types={telTypes}
value={x}
onClearRequest={(name: string) => this.removeValueType(name, idx)}
onChange={(name: string, type: string, value: string) => (
this.handleValueTypeChange(name, idx, { type, value })
)}
/>
))}
<div>
Emails
<IconButton
onClick={() => this.addValueType("email")}
title="Add email address"
>
<IconAdd />
</IconButton>
</div>
{this.state.email.map((x, idx) => (
<ValueTypeComponent
key={idx}
name="email"
placeholder="Email"
types={emailTypes}
value={x}
onClearRequest={(name: string) => this.removeValueType(name, idx)}
onChange={(name: string, type: string, value: string) => (
this.handleValueTypeChange(name, idx, { type, value })
)}
/>
))}
<div>
IMPP
<IconButton
onClick={() => this.addValueType("impp", "jabber")}
title="Add impp address"
>
<IconAdd />
</IconButton>
</div>
{this.state.impp.map((x, idx) => (
<ValueTypeComponent
key={idx}
name="impp"
placeholder="IMPP"
types={imppTypes}
value={x}
onClearRequest={(name: string) => this.removeValueType(name, idx)}
onChange={(name: string, type: string, value: string) => (
this.handleValueTypeChange(name, idx, { type, value })
)}
/>
))}
<div>
Addresses
<IconButton
onClick={() => this.addValueType("address")}
title="Add address"
>
<IconAdd />
</IconButton>
</div>
{this.state.address.map((x, idx) => (
<ValueTypeComponent
key={idx}
name="address"
placeholder="Address"
types={addressTypes}
multiline
value={x}
onClearRequest={(name: string) => this.removeValueType(name, idx)}
onChange={(name: string, type: string, value: string) => (
this.handleValueTypeChange(name, idx, { type, value })
)}
/>
))}
<TextField
name="org"
placeholder="Organization"
style={styles.fullWidth}
value={this.state.org}
onChange={this.handleInputChange}
/>
<TextField
name="title"
placeholder="Title"
style={styles.fullWidth}
value={this.state.title}
onChange={this.handleInputChange}
/>
<TextField
name="note"
multiline
placeholder="Note"
style={styles.fullWidth}
value={this.state.note}
onChange={this.handleInputChange}
/>
<Autocomplete
style={styles.fullWidth}
freeSolo
multiple
clearOnBlur
selectOnFocus
options={Object.keys(this.state.collectionGroups)}
value={this.state.newGroups}
onChange={(_e, value) => this.handleChange("newGroups", value)}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
label="Groups"
fullWidth
/>
)}
/>
<div style={styles.submit}>
<Button
variant="contained"
onClick={this.props.onCancel}
>
<IconCancel style={{ marginRight: 8 }} />
Cancel
</Button>
{this.props.item &&
<Button
variant="contained"
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
onClick={this.onDeleteRequest}
>
<IconDelete style={{ marginRight: 8 }} />
Delete
</Button>
}
<Button
type="submit"
variant="contained"
color="secondary"
style={{ marginLeft: 15 }}
>
<IconSave style={{ marginRight: 8 }} />
Save
</Button>
</div>
</form>
<ConfirmationDialog
title="Delete Confirmation"
labelOk="Delete"
open={this.state.showDeleteDialog}
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
onCancel={() => this.setState({ showDeleteDialog: false })}
>
Are you sure you would like to delete this contact?
</ConfirmationDialog>
</React.Fragment>
);
}
}
export default ContactEdit;

@ -0,0 +1,288 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import InputLabel from "@material-ui/core/InputLabel";
import * as colors from "@material-ui/core/colors";
import IconDelete from "@material-ui/icons/Delete";
import IconCancel from "@material-ui/icons/Clear";
import IconSave from "@material-ui/icons/Save";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import { CachedCollection } from "../Pim/helpers";
import * as uuid from "uuid";
import * as ICAL from "ical.js";
import { ContactType } from "../pim-types";
import { History } from "history";
class ValueType {
public type: string;
public value: string;
constructor(type?: string, value?: string) {
this.type = type ? type : "home";
this.value = value ? value : "";
}
}
interface PropsType {
collections: CachedCollection[];
initialCollection?: string;
item?: ContactType;
onSave: (contact: ContactType, collectionUid: string, originalContact?: ContactType) => Promise<void>;
onDelete: (contact: ContactType, collectionUid: string) => void;
onCancel: () => void;
history: History<any>;
allGroups: ContactType[];
}
class GroupEdit extends React.PureComponent<PropsType> {
public state: {
uid: string;
fn: string;
collectionUid: string;
showDeleteDialog: boolean;
collectionGroups: {};
showError: boolean;
};
constructor(props: PropsType) {
super(props);
this.state = {
uid: "",
fn: "",
collectionUid: "",
showDeleteDialog: false,
collectionGroups: {},
showError: false,
};
if (this.props.item !== undefined) {
const group = this.props.item;
this.state.uid = group.uid;
this.state.fn = group.fn;
} else {
this.state.uid = uuid.v4();
}
if (props.initialCollection) {
this.state.collectionUid = props.initialCollection;
} else if (props.collections[0]) {
this.state.collectionUid = props.collections[0].collection.uid;
}
this.state.collectionGroups = this.getCollectionGroups(this.state.collectionUid);
this.onSubmit = this.onSubmit.bind(this);
this.getCollectionGroups = this.getCollectionGroups.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleCollectionChange = this.handleCollectionChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleValueTypeChange = this.handleValueTypeChange.bind(this);
this.addValueType = this.addValueType.bind(this);
this.removeValueType = this.removeValueType.bind(this);
this.onDeleteRequest = this.onDeleteRequest.bind(this);
}
public addValueType(name: string, _type?: string) {
const type = _type ? _type : "home";
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray.push(new ValueType(type));
return {
...prevState,
[name]: newArray,
};
});
}
public removeValueType(name: string, idx: number) {
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray.splice(idx, 1);
return {
...prevState,
[name]: newArray,
};
});
}
public handleValueTypeChange(name: string, idx: number, value: ValueType) {
this.setState((prevState) => {
const newArray = prevState[name].slice(0);
newArray[idx] = value;
return {
...prevState,
[name]: newArray,
};
});
}
public handleChange(name: string, value: string) {
this.setState({
[name]: value,
});
}
public getCollectionGroups(collectionUid: string) {
const groups = {};
this.props.allGroups.forEach((group) => {
if (collectionUid === group.collectionUid) {
groups[group.fn] = null;
}
});
return groups;
}
public handleCollectionChange(contact: any) {
const name = contact.target.name;
const collectionUid: string = contact.target.value;
this.handleChange(name, collectionUid);
this.setState({ "collectionGroups": this.getCollectionGroups(collectionUid) });
}
public handleInputChange(contact: any) {
const name = contact.target.name;
const value = contact.target.value;
this.handleChange(name, value);
}
public onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
const nameUsed = this.state.fn in this.state.collectionGroups;
if ((this.props.item && this.state.fn !== this.props.item.fn && nameUsed) || (!this.props.item && nameUsed)) {
this.setState({ showError: true });
return;
}
const group = (this.props.item) ?
this.props.item.clone()
:
new ContactType(new ICAL.Component(["vcard", [], []]))
;
const comp = group.comp;
comp.updatePropertyWithValue("prodid", "-//iCal.js EteSync Web");
comp.updatePropertyWithValue("version", "4.0");
comp.updatePropertyWithValue("uid", this.state.uid);
comp.updatePropertyWithValue("rev", ICAL.Time.now());
comp.updatePropertyWithValue("kind", "group");
comp.updatePropertyWithValue("fn", this.state.fn.trim());
this.props.onSave(group, this.state.collectionUid, this.props.item)
.then(() => {
this.props.history.goBack();
});
}
public onDeleteRequest() {
this.setState({
showDeleteDialog: true,
});
}
public render() {
const styles = {
form: {
},
fullWidth: {
width: "100%",
boxSizing: "border-box" as any,
},
submit: {
marginTop: 40,
marginBottom: 20,
textAlign: "right" as any,
},
};
return (
<React.Fragment>
<h2>
{this.props.item ? "Edit Group" : "New Group"}
</h2>
<form style={styles.form} onSubmit={this.onSubmit}>
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
<InputLabel>
Saving to
</InputLabel>
<Select
name="collectionUid"
value={this.state.collectionUid}
onChange={this.handleCollectionChange}
>
{this.props.collections.map((x) => (
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
))}
</Select>
</FormControl>
<TextField
name="fn"
placeholder="Name"
error={this.state.showError}
helperText="Group names must be unique"
style={{ marginTop: "2rem", ...styles.fullWidth }}
value={this.state.fn}
onChange={this.handleInputChange}
/>
<div style={styles.submit}>
<Button
variant="contained"
onClick={this.props.onCancel}
>
<IconCancel style={{ marginRight: 8 }} />
Cancel
</Button>
{this.props.item &&
<Button
variant="contained"
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
onClick={this.onDeleteRequest}
>
<IconDelete style={{ marginRight: 8 }} />
Delete
</Button>
}
<Button
type="submit"
variant="contained"
color="secondary"
style={{ marginLeft: 15 }}
disabled={this.state.fn.length === 0}
>
<IconSave style={{ marginRight: 8 }} />
Save
</Button>
</div>
</form>
<ConfirmationDialog
title="Delete Confirmation"
labelOk="Delete"
open={this.state.showDeleteDialog}
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
onCancel={() => this.setState({ showDeleteDialog: false })}
>
Are you sure you would like to delete this group?
</ConfirmationDialog>
</React.Fragment>
);
}
}
export default GroupEdit;

@ -0,0 +1,242 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Switch, Route, useHistory } from "react-router";
import * as Etebase from "etebase";
import { Button, useTheme } from "@material-ui/core";
import IconEdit from "@material-ui/icons/Edit";
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
import { ContactType, PimType } from "../pim-types";
import { useCredentials } from "../credentials";
import { useItems, useCollections } from "../etebase-helpers";
import { routeResolver } from "../App";
import SearchableAddressBook from "./SearchableAddressBook";
import Contact from "./Contact";
import LoadingIndicator from "../widgets/LoadingIndicator";
import ContactEdit from "./ContactEdit";
import GroupEdit from "./GroupEdit";
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemSave, itemDelete } from "../Pim/helpers";
import ItemChangeHistory from "../Pim/ItemChangeHistory";
const colType = "etebase.vcard";
const decryptCollections = getDecryptCollectionsFunction(colType);
const decryptItems = getDecryptItemsFunction(colType, ContactType.parse);
export default function ContactsMain() {
const [entries, setEntries] = React.useState<Map<string, Map<string, ContactType>>>();
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
const theme = useTheme();
const history = useHistory();
const etebase = useCredentials()!;
const collections = useCollections(etebase, colType);
const items = useItems(etebase, colType);
React.useEffect(() => {
if (!collections || !items) {
return;
}
(async () => {
const colEntries = await decryptCollections(collections);
const entries = await decryptItems(items);
setCachedCollections(colEntries);
setEntries(entries);
})();
}, [items, collections]);
if (!entries || !cachedCollections) {
return (
<LoadingIndicator />
);
}
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
const collection = collections!.find((x) => x.uid === collectionUid)!;
await itemSave(etebase, collection, items!, item, collectionUid, originalItem);
}
async function onItemDelete(item: PimType, collectionUid: string) {
const collection = collections!.find((x) => x.uid === collectionUid)!;
await itemDelete(etebase, collection, items!, item, collectionUid);
history.push(routeResolver.getRoute("pim.contacts"));
}
function onCancel() {
history.goBack();
}
const flatEntries = [];
for (const col of entries.values()) {
for (const item of col.values()) {
flatEntries.push(item);
}
}
const allGroups = flatEntries.filter((x) => x.group);
const styles = {
button: {
marginLeft: theme.spacing(1),
},
leftIcon: {
marginRight: theme.spacing(1),
},
};
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.contacts")}
exact
>
<SearchableAddressBook
entries={flatEntries}
onItemClick={(item) => history.push(
routeResolver.getRoute("pim.contacts._id", { itemUid: getItemNavigationUid(item) })
)}
onNewGroupClick={() => history.push(
routeResolver.getRoute("pim.contacts.new.group")
)}
/>
<PimFab
onClick={() => history.push(
routeResolver.getRoute("pim.contacts.new.contact")
)}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts.new.group")}
exact
>
<GroupEdit
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts.new.contact")}
exact
>
<ContactEdit
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.contacts._id.log")}
render={({ match }) => {
// We have this path outside because we don't want the item existing check
const [colUid, itemUid] = match.params.itemUid.split("|");
const cachedCollection = cachedCollections!.find((x) => x.collection.uid === colUid)!;
if (!cachedCollection) {
return (<PageNotFound />);
}
return (
<ItemChangeHistory collection={cachedCollection} itemUid={itemUid} />
);
}}
/>
<Route
path={routeResolver.getRoute("pim.contacts._id")}
render={({ match }) => {
const [colUid, itemUid] = match.params.itemUid.split("|");
const item = entries.get(colUid)?.get(itemUid);
if (!item) {
return (<PageNotFound />);
}
const collection = collections!.find((x) => x.uid === colUid)!;
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
const path = `pim.contacts._id.edit.${item.group ? "group" : "contact"}`;
return (
<Switch>
<Route
path={routeResolver.getRoute(path)}
exact
>
{item.group ?
<GroupEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
:
<ContactEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
allGroups={allGroups}
/>
}
</Route>
<Route
path={routeResolver.getRoute("pim.contacts._id")}
exact
>
<div style={{ textAlign: "right", marginBottom: 15 }}>
<Button
variant="contained"
style={styles.button}
onClick={() =>
history.push(routeResolver.getRoute("pim.contacts._id.log", { itemUid: getItemNavigationUid(item) }))
}
>
<IconChangeHistory style={styles.leftIcon} />
Change History
</Button>
<Button
color="secondary"
variant="contained"
disabled={readOnly}
style={{ ...styles.button, marginLeft: 15 }}
onClick={() =>
history.push(routeResolver.getRoute(path, { itemUid: getItemNavigationUid(item) }))
}
>
<IconEdit style={styles.leftIcon} />
Edit
</Button>
</div>
<Contact item={item} />
</Route>
<PageNotFoundRoute />
</Switch>
);
}}
/>
<PageNotFoundRoute />
</Switch>
);
}

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { makeStyles, useTheme } from "@material-ui/core/styles";
import Divider from "@material-ui/core/Divider";
import Grid from "@material-ui/core/Grid";
import { ContactType } from "../pim-types";
import Sidebar from "./Sidebar";
import Toolbar from "./Toolbar";
import AddressBook from "./AddressBook";
const useStyles = makeStyles((theme) => ({
topBar: {
backgroundColor: theme.palette.primary[500],
},
}));
interface PropsType {
entries: ContactType[];
onItemClick: (contact: ContactType) => void;
onNewGroupClick: () => void;
}
export default function SearchableAddressBook(props: PropsType) {
const [searchQuery, setSearchQuery] = React.useState("");
const [filterByGroup, setFilterByGroup] = React.useState<string>();
const theme = useTheme();
const classes = useStyles();
const groups = React.useMemo(
(() => props.entries.filter((x) => x.group)),
[props.entries]
);
const group = React.useMemo(
(() => groups.find((x) => x.uid === filterByGroup)),
[groups, filterByGroup]
);
function filterFunc(ent: ContactType) {
return (
(!group || (group.members.includes(ent.uid))) &&
ent.fn?.match(reg)
);
}
const reg = new RegExp(searchQuery, "i");
return (
<Grid container spacing={4}>
<Grid item xs={3} className={classes.topBar}>
{/* spacer */}
</Grid>
<Grid item xs={9} className={classes.topBar}>
<Toolbar
searchTerm={searchQuery}
setSearchTerm={setSearchQuery}
/>
</Grid>
<Grid item xs={3} style={{ borderRight: `1px solid ${theme.palette.divider}` }}>
<Sidebar
groups={groups}
filterByGroup={filterByGroup}
setFilterByGroup={setFilterByGroup}
newGroup={props.onNewGroupClick}
editGroup={props.onItemClick}
/>
</Grid>
<Grid item xs>
<Divider style={{ marginTop: "1em" }} />
<AddressBook filter={filterFunc} {...props} />
</Grid>
</Grid>
);
}

@ -0,0 +1,93 @@
import * as React from "react";
import InboxIcon from "@material-ui/icons/Inbox";
import LabelIcon from "@material-ui/icons/LabelOutlined";
import AddIcon from "@material-ui/icons/Add";
import EditIcon from "@material-ui/icons/EditOutlined";
import IconButton from "@material-ui/core/IconButton";
import { List, ListItem, ListSubheader } from "../widgets/List";
import { ContactType } from "../pim-types";
interface ListItemPropsType {
name: string | undefined;
icon?: React.ReactElement;
primaryText: string;
filterByGroup: string | undefined;
setFilterByGroup: (group: string | undefined) => void;
editGroup: () => void;
}
function SidebarListItem(props: ListItemPropsType) {
const { name, icon, primaryText, filterByGroup, editGroup } = props;
const handleClick = () => props.setFilterByGroup(name);
const selected = name === filterByGroup;
return (
<ListItem
onClick={handleClick}
selected={selected}
leftIcon={icon}
primaryText={primaryText}
secondaryAction={name && selected &&
<IconButton onClick={editGroup}>
<EditIcon />
</IconButton>
}
/>
);
}
interface PropsType {
groups: ContactType[];
filterByGroup: string | undefined;
setFilterByGroup: (group: string | undefined) => void;
newGroup: () => void;
editGroup: (group: ContactType) => void;
}
export default React.memo(function Sidebar(props: PropsType) {
const { groups, filterByGroup, setFilterByGroup, newGroup, editGroup } = props;
const groupList = [...groups].sort((a, b) => a.fn.localeCompare(b.fn)).map((group) => (
<SidebarListItem
key={group.uid}
name={group.uid}
primaryText={group.fn}
icon={<LabelIcon />}
filterByGroup={filterByGroup}
setFilterByGroup={setFilterByGroup}
editGroup={() => editGroup(group)}
/>
));
return (
<List dense>
<SidebarListItem
name={undefined}
primaryText="All"
icon={<InboxIcon />}
filterByGroup={filterByGroup}
setFilterByGroup={setFilterByGroup}
editGroup={newGroup}
/>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<ListSubheader>
Groups
</ListSubheader>
<IconButton
edge="end"
onClick={newGroup}
>
<AddIcon />
</IconButton>
</div>
{groupList}
</List>
);
});

@ -0,0 +1,64 @@
import * as React from "react";
import SearchIcon from "@material-ui/icons/Search";
import TextField from "@material-ui/core/TextField";
import { makeStyles } from "@material-ui/core/styles";
import { Transition } from "react-transition-group";
import InputAdornment from "@material-ui/core/InputAdornment";
const transitionTimeout = 300;
const transitionStyles = {
entering: { visibility: "visible", width: "100%", overflow: "hidden" },
entered: { visibility: "visible", width: "100%" },
exiting: { visibility: "visible", width: "0%", overflow: "hidden" },
exited: { visibility: "hidden", width: "0%" },
};
const useStyles = makeStyles((theme) => ({
button: {
marginRight: theme.spacing(1),
},
textField: {
transition: `width ${transitionTimeout}ms`,
marginRight: theme.spacing(1),
},
}));
interface PropsType {
searchTerm: string;
setSearchTerm: (term: string) => void;
}
export default function Toolbar(props: PropsType) {
const { searchTerm, setSearchTerm } = props;
const showSearchField = true;
const classes = useStyles();
return (
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center" }}>
<Transition in={showSearchField} timeout={transitionTimeout}>
{(state) => (
<TextField
fullWidth
placeholder="Search"
value={searchTerm}
color="secondary"
variant="standard"
className={classes.textField}
style={transitionStyles[state]}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
</Transition>
</div>
);
}

@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Container from "./widgets/Container";
import { useSelector } from "react-redux";
import { StoreState } from "./store";
import { useCredentials } from "./credentials";
import { getCollectionManager } from "./etebase-helpers";
export default function Debug() {
const etebase = useCredentials()!;
const [stateCollectionUid, setCollectionUid] = React.useState("");
const [itemsUids, setEntriesUids] = React.useState("");
const [result, setResult] = React.useState("");
const cacheCollections = useSelector((state: StoreState) => state.cache.collections);
const cacheItems = useSelector((state: StoreState) => state.cache.items);
function handleInputChange(func: (value: string) => void) {
return (event: React.ChangeEvent<any>) => {
func(event.target.value);
};
}
return (
<Container>
<div>
<TextField
style={{ width: "100%" }}
type="text"
label="Collection UID"
value={stateCollectionUid}
onChange={handleInputChange(setCollectionUid)}
/>
</div>
<div>
<TextField
style={{ width: "100%" }}
type="text"
multiline
label="Item UIDs"
value={itemsUids}
onChange={handleInputChange(setEntriesUids)}
/>
</div>
<Button
variant="contained"
color="secondary"
onClick={async () => {
const colUid = stateCollectionUid.trim();
const cachedCollection = cacheCollections.get(colUid);
const colItems = cacheItems.get(colUid);
if (!colItems || !cachedCollection) {
setResult("Error: collection uid not found.");
return;
}
const colMgr = getCollectionManager(etebase);
const col = colMgr.cacheLoad(cachedCollection);
const itemMgr = colMgr.getItemManager(col);
const wantedEntries = {};
const wantAll = (itemsUids.trim() === "all");
itemsUids.split("\n").forEach((ent) => wantedEntries[ent.trim()] = true);
const retEntries = [];
console.log(wantAll, colItems.size);
for (const cached of colItems.values()) {
const item = itemMgr.cacheLoad(cached);
const meta = item.getMeta();
const content = await item.getContent(Etebase.OutputFormat.String);
if (wantAll || wantedEntries[item.uid]) {
retEntries.push(`${JSON.stringify(meta)}\n${content}`);
}
}
setResult(retEntries.join("\n\n"));
}}
>
Decrypt
</Button>
<div>
<p>Result:</p>
<pre>{result}</pre>
</div>
</Container>
);
}

@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { useDispatch } from "react-redux";
import Container from "./widgets/Container";
import ExternalLink from "./widgets/ExternalLink";
import LoginForm from "./components/LoginForm";
import { login } from "./store/actions";
import * as Etebase from "etebase";
import * as C from "./constants";
import SignedPagesBadge from "./images/signed-pages-badge.svg";
import { useCredentials } from "./credentials";
import LoadingIndicator from "./widgets/LoadingIndicator";
import { startTask } from "./helpers";
import { Redirect, useLocation } from "react-router";
import { routeResolver } from "./App";
import { Link } from "react-router-dom";
export default function LoginPage() {
const credentials = useCredentials();
const dispatch = useDispatch();
const location = useLocation();
const [loading, setLoading] = React.useState(false);
const [fetchError, setFetchError] = React.useState<Error>();
if (credentials) {
return (
<Redirect to={{ pathname: routeResolver.getRoute("wizard"), state: location.state }} />
);
}
async function onFormSubmit(username: string, password: string, serviceApiUrl?: string) {
try {
setLoading(true);
setFetchError(undefined);
const etebase = await startTask((async () => {
return await Etebase.Account.login(username, password, serviceApiUrl ?? C.defaultServerUrl);
}));
dispatch(login(etebase));
} catch (e) {
console.log(e);
if ((e instanceof Etebase.HttpError) && (e.status === 404)) {
setFetchError(new Error("Etebase server not found: are you sure the server URL is correct?"));
} else {
setFetchError(e);
}
} finally {
setLoading(false);
}
}
if (credentials === undefined) {
return (
<LoadingIndicator />
);
} else {
const style = {
isSafe: {
textDecoration: "none",
display: "block",
},
divider: {
margin: "30px 0",
color: "#00000025",
},
};
return (
<Container style={{ maxWidth: "30rem" }}>
<h2 style={{ marginBottom: "0.1em" }}>Log In</h2>
<div style={{ fontSize: "90%" }}>or <Link to={routeResolver.getRoute("signup")}>create an account</Link></div>
<LoginForm
onSubmit={onFormSubmit}
loading={loading}
error={fetchError}
/>
<hr style={style.divider} />
<ExternalLink style={style.isSafe} href="https://www.etesync.com/faq/#signed-pages">
<img alt="SignedPgaes badge" src={SignedPagesBadge} />
</ExternalLink>
<ul>
<li><ExternalLink style={style.isSafe} href={C.homePage}>
The EteSync Website
</ExternalLink></li>
<li><ExternalLink style={style.isSafe} href={C.faq + "#web-client"}>
Is the web client safe to use?
</ExternalLink></li>
<li><ExternalLink style={style.isSafe} href={C.sourceCode}>Source code</ExternalLink></li>
</ul>
</Container>
);
}
}

@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Route, Switch, Redirect, RouteProps } from "react-router";
import { useCredentials } from "./credentials";
import LoadingIndicator from "./widgets/LoadingIndicator";
import SyncGate from "./SyncGate";
import { routeResolver } from "./App";
import SignupPage from "./SignupPage";
import LoginPage from "./LoginPage";
import WizardPage from "./WizardPage";
import { Snackbar } from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import { useSelector, useDispatch } from "react-redux";
import { StoreState } from "./store";
import { popMessage } from "./store/actions";
export default function MainRouter() {
return (
<>
<Switch>
<Route
path={routeResolver.getRoute("signup")}
exact
>
<SignupPage />
</Route>
<Route
path={routeResolver.getRoute("login")}
exact
>
<LoginPage />
</Route>
<PrivateRoute
path={routeResolver.getRoute("wizard")}
exact
>
<WizardPage />
</PrivateRoute>
<PrivateRoute
path="*"
>
<SyncGate />
</PrivateRoute>
</Switch>
<GlobalMessages />
</>
);
}
function GlobalMessages() {
const dispatch = useDispatch();
const message = useSelector((state: StoreState) => state.messages.first(undefined));
function handleClose() {
dispatch(popMessage());
}
return (
<Snackbar
key={message?.message}
open={!!message}
autoHideDuration={5000}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={handleClose} severity={message?.severity}>
{message?.message}
</Alert>
</Snackbar>
);
}
function PrivateRoute(props: Omit<RouteProps, "render">) {
const credentials = useCredentials();
const { children, ...rest } = props;
if (credentials === undefined) {
return (<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />);
}
return (
<Route
{...rest}
render={({ location }) => (
(credentials) ? (
children
) : (
<Redirect
to={{
pathname: "/login",
state: { from: location },
}}
/>
)
)}
/>
);
}

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Route } from "react-router";
import Container from "./widgets/Container";
export function PageNotFoundRoute(props: { container?: boolean }) {
return (
<Route path="*">
{props.container ? (
<Container>
<PageNotFound />
</Container>
) : (
<PageNotFound />
)}
</Route>
);
}
export default function PageNotFound() {
return (
<h1>404 Page Not Found</h1>
);
}

@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import Dialog from "@material-ui/core/Dialog";
import DialogTitle from "@material-ui/core/DialogTitle";
import DialogContent from "@material-ui/core/DialogContent";
import DialogActions from "@material-ui/core/DialogActions";
import Button from "@material-ui/core/Button";
import { useCredentials } from "../credentials";
import LoadingIndicator from "../widgets/LoadingIndicator";
import GenericChangeHistory from "../components/GenericChangeHistory";
import { useItems } from "../etebase-helpers";
import { CachedCollection } from "./helpers";
import PageNotFound from "../PageNotFound";
export interface CachedItem {
item: Etebase.Item;
metadata: Etebase.ItemMetadata;
content: string;
}
async function loadRevisions(etebase: Etebase.Account, col: Etebase.Collection, item: Etebase.Item) {
const ret: CachedItem[] = [];
const colMgr = etebase.getCollectionManager();
const itemManager = colMgr.getItemManager(col);
let iterator: string | null = null;
let done = false;
while (!done) {
const revisions = await itemManager.itemRevisions(item, { iterator, limit: 30 });
iterator = revisions.iterator as string;
done = revisions.done;
for (const item of revisions.data) {
ret.push({
item,
metadata: item.getMeta(),
content: await item.getContent(Etebase.OutputFormat.String),
});
}
}
return ret;
}
interface PropsType {
collection: CachedCollection;
itemUid: string;
}
export default function ItemChangeHistory(props: PropsType) {
const [entries, setEntries] = React.useState<CachedItem[]>();
const [dialog, setDialog] = React.useState<CachedItem>();
const etebase = useCredentials()!;
const { collection, collectionType } = props.collection;
const items = useItems(etebase, collectionType);
const item = items?.get(collection.uid)?.get(props.itemUid);
React.useEffect(() => {
if (item) {
loadRevisions(etebase, collection, item)
.then((entries) => setEntries(entries));
}
}, [etebase, collection, item]);
if (!item) {
return (<PageNotFound />);
}
if (!entries) {
return (
<LoadingIndicator />
);
}
return (
<div style={{ height: "calc(100vh - 300px)" }}>
<Dialog
open={dialog !== undefined}
onClose={() => setDialog(undefined)}
>
<DialogTitle>
Raw Content
</DialogTitle>
<DialogContent>
<div>Revision UID: <pre className="d-inline-block">{dialog?.item.etag}</pre></div>
<div>Content:
<pre>{dialog?.content}</pre>
</div>
</DialogContent>
<DialogActions>
<Button
color="primary"
onClick={() => setDialog(undefined)}
>
Close
</Button>
</DialogActions>
</Dialog>
<GenericChangeHistory
items={entries}
onItemClick={setDialog}
/>
</div>
);
}

@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Tabs, Tab, useTheme } from "@material-ui/core";
import { useHistory } from "react-router";
import { routeResolver } from "../App";
export type TabValue = "contacts" | "events" | "tasks";
interface PropsType {
value: TabValue;
}
export default function PimNavigationTabs(props: PropsType) {
const theme = useTheme();
const history = useHistory();
const tabs = [
{ title: "Address Book", linkValue: "contacts" },
{ title: "Calendar", linkValue: "events" },
{ title: "Tasks", linkValue: "tasks" },
];
let selected;
switch (props.value) {
case "contacts": {
selected = 0;
break;
}
case "events": {
selected = 1;
break;
}
case "tasks": {
selected = 2;
break;
}
}
return (
<Tabs
variant="fullWidth"
style={{ backgroundColor: theme.palette.primary.main }}
value={selected}
onChange={(_event, value) => history.push(
routeResolver.getRoute(`pim.${tabs[value].linkValue}`)
)}
>
{tabs.map((x) => (
<Tab
key={x.linkValue}
label={x.title}
/>
))}
</Tabs>
);
}

@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Fab } from "@material-ui/core";
import ContentAdd from "@material-ui/icons/Add";
import memoize from "memoizee";
import * as Etebase from "etebase";
import { PimType } from "../pim-types";
import { getCollectionManager } from "../etebase-helpers";
import { asyncDispatch, store } from "../store";
import { itemBatch, appendError } from "../store/actions";
export const defaultColor = "#8BC34A";
export interface CachedCollection {
collection: Etebase.Collection;
metadata: Etebase.ItemMetadata;
collectionType: string;
}
export function getRawItemNavigationUid(collectionUid: string, itemUid: string) {
// Both collectionUid and itemUid are url safe
return `${collectionUid}|${itemUid}`;
}
export function getItemNavigationUid(item: PimType) {
return getRawItemNavigationUid(item.collectionUid!, item.itemUid!);
}
export function getDecryptCollectionsFunction(_colType?: string) {
return memoize(
async function (collections: Etebase.Collection[]) {
const entries: CachedCollection[] = [];
if (collections) {
for (const collection of collections) {
try {
entries.push({
collection,
metadata: collection.getMeta(),
collectionType: collection.getCollectionType(),
});
} catch (e) {
store.dispatch(appendError(e));
}
}
}
return entries;
},
{ max: 1 }
);
}
export function getDecryptItemsFunction<T extends PimType>(_colType: string, parseFunc: (str: string) => T) {
return memoize(
async function (items: Map<string, Map<string, Etebase.Item>>) {
const entries: Map<string, Map<string, T>> = new Map();
if (items) {
for (const [colUid, col] of items.entries()) {
const cur = new Map();
entries.set(colUid, cur);
for (const item of col.values()) {
if (item.isDeleted) {
continue;
}
try {
const contact = parseFunc(await item.getContent(Etebase.OutputFormat.String));
contact.collectionUid = colUid;
contact.itemUid = item.uid;
cur.set(item.uid, contact);
} catch (e) {
store.dispatch(appendError(e));
}
}
}
}
return entries;
},
{ max: 1 }
);
}
export async function itemSave(etebase: Etebase.Account, collection: Etebase.Collection, items: Map<string, Map<string, Etebase.Item>>, item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
const itemUid = originalItem?.itemUid;
const colMgr = getCollectionManager(etebase);
const itemMgr = colMgr.getItemManager(collection);
const mtime = (new Date()).getTime();
const content = item.toIcal();
let eteItem;
if (itemUid) {
// Existing item
eteItem = items!.get(collectionUid)?.get(itemUid)!;
await eteItem.setContent(content);
const meta = eteItem.getMeta();
meta.mtime = mtime;
eteItem.setMeta(meta);
} else {
// New
const meta: Etebase.ItemMetadata = {
mtime,
name: item.uid,
};
eteItem = await itemMgr.create(meta, content);
}
await asyncDispatch(itemBatch(collection, itemMgr, [eteItem]));
}
export async function itemDelete(etebase: Etebase.Account, collection: Etebase.Collection, items: Map<string, Map<string, Etebase.Item>>, item: PimType, collectionUid: string) {
const itemUid = item.itemUid!;
const colMgr = getCollectionManager(etebase);
const itemMgr = colMgr.getItemManager(collection);
const eteItem = items!.get(collectionUid)?.get(itemUid)!;
const mtime = (new Date()).getTime();
const meta = eteItem.getMeta();
meta.mtime = mtime;
eteItem.setMeta(meta);
eteItem.delete(true);
await asyncDispatch(itemBatch(collection, itemMgr, [eteItem]));
}
interface PimFabPropsType {
onClick: () => void;
}
export function PimFab(props: PimFabPropsType) {
const style = {
floatingButton: {
margin: 0,
top: "auto",
right: 20,
bottom: 20,
left: "auto",
position: "fixed",
},
};
return (
<Fab
color="primary"
style={style.floatingButton as any}
onClick={props.onClick}
>
<ContentAdd />
</Fab>
);
}

@ -0,0 +1,246 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import { useSelector, useDispatch } from "react-redux";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import FormGroup from "@material-ui/core/FormGroup";
import Switch from "@material-ui/core/Switch";
import InputLabel from "@material-ui/core/InputLabel";
import { StoreState } from "../store";
import { setSettings, login, pushMessage } from "../store/actions";
import Container from "../widgets/Container";
import AppBarOverride from "../widgets/AppBarOverride";
import PrettyFingerprint from "../widgets/PrettyFingerprint";
import { useCredentials } from "../credentials";
import { Button } from "@material-ui/core";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import PasswordField from "../widgets/PasswordField";
import Alert from "@material-ui/lab/Alert";
import { PASSWORD_MIN_LENGTH, startTask, enforcePasswordRules } from "../helpers";
function SecurityFingerprint() {
const etebase = useCredentials()!;
const inviteMgr = etebase.getInvitationManager();
const publicKey = inviteMgr.pubkey;
return (
<>
<p>
Your security fingerprint is:
</p>
<PrettyFingerprint publicKey={publicKey} />
</>
);
}
interface ChangePasswordFormErrors {
oldPassword?: string;
newPassword?: string;
general?: string;
}
function ChangePassword() {
const etebase = useCredentials()!;
const dispatch = useDispatch();
const [showDialog, setShowDialog] = React.useState(false);
const [oldPassword, setOldPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [errors, setErrors] = React.useState<ChangePasswordFormErrors>({});
const styles = {
infoAlert: {
marginTop: 20,
},
textField: {
marginTop: 20,
width: "18em",
},
};
function handleInputChange(func: (value: string) => void) {
return (event: React.ChangeEvent<any>) => {
func(event.target.value);
};
}
async function onChangePassword() {
try {
const fieldNotEmpty = "Password can't be empty.";
const errors: ChangePasswordFormErrors = {};
if (!oldPassword) {
errors.oldPassword = fieldNotEmpty;
}
if (!newPassword) {
errors.newPassword = fieldNotEmpty;
} else {
const passwordRulesError = enforcePasswordRules(newPassword);
if (passwordRulesError) {
errors.newPassword = passwordRulesError;
}
}
setErrors(errors);
if (Object.keys(errors).length > 0) {
return;
}
await startTask(async () => {
const serverUrl = etebase.serverUrl;
const username = etebase.user.username;
try {
const etebase = await Etebase.Account.login(username, oldPassword, serverUrl);
await etebase.logout();
} catch (e) {
if (e instanceof Etebase.UnauthorizedError) {
setErrors({ oldPassword: "Error: wrong encryption password." });
} else {
setErrors({ oldPassword: e.toString() });
}
return;
}
try {
await etebase.changePassword(newPassword);
dispatch(login(etebase));
dispatch(pushMessage({ message: "Password successfully changed.", severity: "success" }));
setShowDialog(false);
} catch (e) {
setErrors({ newPassword: e.toString() });
}
});
} finally {
// Cleanup
}
}
return (
<>
<p>
Change your password by clicking here;
</p>
<Button color="secondary" variant="contained" onClick={() => setShowDialog(true)}>
Change Password
</Button>
<ConfirmationDialog
title="Change Password"
key={showDialog}
open={showDialog}
onOk={onChangePassword}
onCancel={() => setShowDialog(false)}
>
<PasswordField
style={styles.textField}
error={!!errors.oldPassword}
helperText={errors.oldPassword}
label="Current Password"
value={oldPassword}
onChange={handleInputChange(setOldPassword)}
/>
<PasswordField
style={styles.textField}
error={!!errors.newPassword}
helperText={errors.newPassword}
label="New Password"
inputProps={{
minLength: PASSWORD_MIN_LENGTH,
}}
value={newPassword}
onChange={handleInputChange(setNewPassword)}
/>
{errors.general && (
<Alert severity="error" style={styles.infoAlert}>{errors.general}</Alert>
)}
<Alert severity="warning" style={styles.infoAlert}>
Please make sure you remember your password, as it <em>can't</em> be recovered if lost!
</Alert>
</ConfirmationDialog>
</>
);
}
export default React.memo(function Settings() {
const etebase = useCredentials();
const dispatch = useDispatch();
const settings = useSelector((state: StoreState) => state.settings);
const darkMode = !!settings.darkMode;
function handleChange(event: React.ChangeEvent<any>) {
const name = event.target.name;
const value = event.target.value;
dispatch(setSettings({ ...settings, [name]: value }));
}
return (
<>
<AppBarOverride title="Settings" />
<Container>
{(etebase) && (
<>
<h1>Account</h1>
<h2>Security Fingerprint</h2>
<SecurityFingerprint />
<h2>Account Dashboard</h2>
<p>
Change your payment info, plan and other account settings
</p>
<Button color="secondary" variant="contained" onClick={async () => {
try {
const url = await etebase!.getDashboardUrl();
window.open(url, "_blank", "noopener,noreferrer");
} catch (e) {
dispatch(pushMessage({ message: e.message, severity: "error" }));
}
}}>
Open Dashboard
</Button>
<h2>Password</h2>
<ChangePassword />
</>
)}
<h1>Look & Feel</h1>
<h2>Date & Time</h2>
<FormControl style={{ width: "15em" }}>
<InputLabel>
Locale
</InputLabel>
<Select
name="locale"
value={settings.locale}
onChange={handleChange}
>
<MenuItem value="en-gb">English (United Kingdom)</MenuItem>
<MenuItem value="en-us">English (United States)</MenuItem>
</Select>
</FormControl>
<h2>Dark mode</h2>
<FormGroup>
<FormControlLabel
control={
<Switch
color="primary"
checked={darkMode}
onChange={() => dispatch(setSettings({ ...settings, darkMode: !darkMode }))}
/>
}
label="Dark mode"
/>
</FormGroup>
</Container>
</>
);
});

@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { List, ListItem, ListSubheader, ListDivider } from "../widgets/List";
import ActionCode from "@material-ui/icons/Code";
import ActionHome from "@material-ui/icons/Home";
import ActionSettings from "@material-ui/icons/Settings";
import ActionJournals from "@material-ui/icons/LibraryBooks";
import ActionBugReport from "@material-ui/icons/BugReport";
import ActionQuestionAnswer from "@material-ui/icons/QuestionAnswer";
import LogoutIcon from "@material-ui/icons/PowerSettingsNew";
import IconImport from "@material-ui/icons/ImportExport";
import IconInvitation from "@material-ui/icons/MailOutline";
import logo from "../images/logo.svg";
import { routeResolver } from "../App";
import { store } from "../store";
import { logout } from "../store/actions";
import * as C from "../constants";
import { useTheme } from "@material-ui/core";
import { useCredentials } from "../credentials";
import { useHistory } from "react-router";
interface PropsType {
onCloseDrawerRequest: () => void;
}
export default function SideMenu(props: PropsType) {
const theme = useTheme();
const etebase = useCredentials();
const username = etebase?.user.username ?? C.appName;
const history = useHistory();
function logoutDo() {
store.dispatch(logout(etebase!));
props.onCloseDrawerRequest();
}
let loggedInItems;
if (etebase) {
loggedInItems = (
<React.Fragment>
<ListItem
primaryText="Collections"
leftIcon={<ActionJournals />}
onClick={() => {
props.onCloseDrawerRequest();
history.push(routeResolver.getRoute("collections"));
}}
/>
<ListItem
primaryText="Invitations"
leftIcon={<IconInvitation />}
onClick={() => {
props.onCloseDrawerRequest();
history.push(routeResolver.getRoute("collections.invitations"));
}}
/>
<ListItem
primaryText="Import"
leftIcon={<IconImport />}
onClick={() => {
props.onCloseDrawerRequest();
history.push(routeResolver.getRoute("collections.import"));
}}
/>
<ListItem
primaryText="Settings"
leftIcon={<ActionSettings />}
onClick={() => {
props.onCloseDrawerRequest();
history.push(routeResolver.getRoute("settings"));
}}
/>
<ListItem primaryText="Log Out" leftIcon={<LogoutIcon />} onClick={logoutDo} />
</React.Fragment>
);
}
return (
<div style={{ overflowX: "hidden", width: 250 }}>
<div className="App-drawer-header">
<img alt="App logo" className="App-drawer-logo" src={logo} />
<div style={{ color: theme.palette.secondary.contrastText }}>
{username}
</div>
</div>
<List>
<ListItem
primaryText="Main"
leftIcon={<ActionHome />}
onClick={() => {
props.onCloseDrawerRequest();
history.push(routeResolver.getRoute("home"));
}}
/>
{loggedInItems}
<ListDivider />
<ListSubheader>External Links</ListSubheader>
<ListItem primaryText="Website" leftIcon={<ActionHome />} href={C.homePage} />
<ListItem primaryText="FAQ" leftIcon={<ActionQuestionAnswer />} href={C.faq} />
<ListItem primaryText="Source Code" leftIcon={<ActionCode />} href={C.sourceCode} />
<ListItem primaryText="Report Issue" leftIcon={<ActionBugReport />} href={C.reportIssue} />
</List>
</div>
);
}

@ -0,0 +1,268 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import FormGroup from "@material-ui/core/FormGroup";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import Switch from "@material-ui/core/Switch";
import { routeResolver } from "./App";
import Container from "./widgets/Container";
import PasswordField from "./widgets/PasswordField";
import LoadingIndicator from "./widgets/LoadingIndicator";
import Alert from "@material-ui/lab/Alert";
import { CircularProgress } from "@material-ui/core";
import { Redirect } from "react-router";
import { useCredentials } from "./credentials";
import { useDispatch } from "react-redux";
import { startTask, PASSWORD_MIN_LENGTH, enforcePasswordRules } from "./helpers";
import { login } from "./store/actions";
import { Link } from "react-router-dom";
import * as C from "./constants";
import ExternalLink from "./widgets/ExternalLink";
interface FormErrors {
username?: string;
email?: string;
password?: string;
server?: string;
general?: string;
}
export default function SignupPage() {
const credentials = useCredentials();
const dispatch = useDispatch();
const [username, setUsername] = React.useState("");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [server, setServer] = React.useState("");
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [errors, setErrors] = React.useState<FormErrors>({});
if (credentials) {
return (
<Redirect to={routeResolver.getRoute("wizard")} />
);
}
async function signup(e: React.FormEvent<any>) {
e.preventDefault();
setLoading(true);
try {
const errors: FormErrors = {};
const fieldRequired = "This field is required!";
if (!username) {
errors.username = fieldRequired;
}
if (!email) {
errors.email = fieldRequired;
}
if (!password) {
errors.password = fieldRequired;
} else {
const passwordRulesError = enforcePasswordRules(password);
if (passwordRulesError) {
errors.password = passwordRulesError;
}
}
if (process.env.NODE_ENV !== "development") {
if (showAdvanced && !server.startsWith("https://")) {
errors.server = "Server URI must start with https://";
}
}
if (Object.keys(errors).length) {
setErrors(errors);
return;
} else {
setErrors({});
}
const serverUrl = (showAdvanced) ? server : C.defaultServerUrl;
const user: Etebase.User = {
username,
email,
};
const etebase = await startTask((async () => {
return await Etebase.Account.signup(user, password, serverUrl);
}));
dispatch(login(etebase));
} catch (e) {
if ((e instanceof Etebase.HttpError) && (e.content)) {
let found = false;
if (e.content.errors) {
for (const field of e.content.errors) {
if (field.field === "user.username") {
errors.username = field.detail;
found = true;
} else if (field.field === "user.email") {
errors.email = field.detail;
found = true;
} else if (!field.field) {
errors.general = field.detail;
found = true;
}
}
}
if (!found) {
errors.general = e.content.detail ?? e.toString();
}
} else {
errors.general = e.toString();
}
setErrors(errors);
} finally {
setLoading(false);
}
}
const styles = {
form: {
},
infoAlert: {
marginTop: 20,
},
textField: {
marginTop: 20,
width: "18em",
},
submit: {
marginTop: 20,
textAlign: "right" as any,
},
};
function handleInputChange(func: (value: string) => void) {
return (event: React.ChangeEvent<any>) => {
func(event.target.value);
};
}
let advancedSettings = null;
if (showAdvanced) {
advancedSettings = (
<React.Fragment>
<TextField
type="url"
style={styles.textField}
error={!!errors.server}
helperText={errors.server}
label="Server"
value={server}
onChange={handleInputChange(setServer)}
/>
<br />
</React.Fragment>
);
}
if (loading) {
return (
<div style={{ textAlign: "center" }}>
<LoadingIndicator />
<p>Deriving encryption data...</p>
</div>
);
}
return (
<Container style={{ maxWidth: "30rem" }}>
<h2 style={{ marginBottom: "0.1em" }}>Signup</h2>
<div style={{ fontSize: "90%" }}>or <Link to={routeResolver.getRoute("home")}>log in to your account</Link></div>
<Alert
style={styles.infoAlert}
severity="info"
>
<a href={C.pricing} style={{ color: "inherit", textDecoration: "inherit", display: "block" }}>
You are signing up for a free trial. Click here for pricing information.
</a>
</Alert>
<form style={styles.form} onSubmit={signup}>
<TextField
type="text"
style={styles.textField}
error={!!errors.username}
helperText={errors.username}
label="Username"
value={username}
onChange={handleInputChange(setUsername)}
/>
<br />
<TextField
type="email"
style={styles.textField}
error={!!errors.email}
helperText={errors.email}
label="Email"
value={email}
onChange={handleInputChange(setEmail)}
/>
<br />
<PasswordField
style={styles.textField}
error={!!errors.password}
helperText={errors.password}
label="Password"
name="password"
inputProps={{
minLength: PASSWORD_MIN_LENGTH,
}}
value={password}
onChange={handleInputChange(setPassword)}
/>
<FormGroup>
<FormControlLabel
control={
<Switch
color="primary"
checked={showAdvanced}
onChange={() => setShowAdvanced(!showAdvanced)}
/>
}
label="Advanced settings"
/>
</FormGroup>
{advancedSettings}
{errors.general && (
<Alert severity="error" style={styles.infoAlert}>{errors.general}</Alert>
)}
<Alert severity="warning" style={styles.infoAlert}>
Please make sure you remember your password, as it <em>can't</em> be recovered if lost!
</Alert>
<p style={styles.infoAlert}>
By signing up you agree to our <ExternalLink href={C.terms}>terms of service</ExternalLink>.
</p>
<div style={styles.submit}>
<Button
variant="contained"
type="submit"
color="secondary"
disabled={loading}
>
{loading ? (
<CircularProgress />
) : "Sign Up"
}
</Button>
</div>
</form>
</Container>
);
}

@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import { Route, Switch, Redirect, useHistory } from "react-router";
import moment from "moment";
import "moment/locale/en-gb";
import { routeResolver } from "./App";
import AppBarOverride from "./widgets/AppBarOverride";
import LoadingIndicator from "./widgets/LoadingIndicator";
import Container from "./widgets/Container";
import ContactsMain from "./Contacts/Main";
import CalendarsMain from "./Calendars/Main";
import TasksMain from "./Tasks/Main";
import CollectionsMain from "./Collections/Main";
import Settings from "./Settings";
import Debug from "./Debug";
import { SyncManager } from "./sync/SyncManager";
import { StoreState } from "./store";
import { performSync } from "./store/actions";
import { useCredentials } from "./credentials";
import PimNavigationTabs from "./Pim/NavigationTabs";
import { PageNotFoundRoute } from "./PageNotFound";
export default function SyncGate() {
const etebase = useCredentials();
const settings = useSelector((state: StoreState) => state.settings);
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
// Doing this so we refresh on route changes
useHistory();
React.useEffect(() => {
(async () => {
const syncManager = SyncManager.getManager(etebase!);
const sync = syncManager.sync();
dispatch(performSync(sync));
await sync;
setLoading(false);
})();
}, []);
if (loading) {
return (<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />);
}
// FIXME: Shouldn't be here
moment.locale(settings.locale);
return (
<Switch>
<Route
path={routeResolver.getRoute("home")}
exact
render={() => (
<Redirect to={routeResolver.getRoute("pim")} />
)}
/>
<Route
path={routeResolver.getRoute("pim")}
>
<AppBarOverride title="EteSync" />
<Switch>
<Route
path={routeResolver.getRoute("pim")}
exact
>
<Redirect to={routeResolver.getRoute("pim.events")} />
</Route>
<Route
path={routeResolver.getRoute("pim.contacts")}
>
<PimNavigationTabs value="contacts" />
<Container>
<ContactsMain />
</Container>
</Route>
<Route
path={routeResolver.getRoute("pim.events")}
>
<PimNavigationTabs value="events" />
<Container>
<CalendarsMain />
</Container>
</Route>
<Route
path={routeResolver.getRoute("pim.tasks")}
>
<PimNavigationTabs value="tasks" />
<Container>
<TasksMain />
</Container>
</Route>
<PageNotFoundRoute container />
</Switch>
</Route>
<Route
path={routeResolver.getRoute("collections")}
>
<CollectionsMain />
</Route>
<Route
path={routeResolver.getRoute("settings")}
exact
render={() => (
<Settings />
)}
/>
<Route
path={routeResolver.getRoute("debug")}
exact
render={() => (
<Debug />
)}
/>
<PageNotFoundRoute container />
</Switch>
);
}

@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: © 2020 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { Switch, Route, useHistory } from "react-router";
import * as Etebase from "etebase";
import { Button, useTheme } from "@material-ui/core";
import IconEdit from "@material-ui/icons/Edit";
import IconChangeHistory from "@material-ui/icons/ChangeHistory";
import { TaskType, PimType } from "../pim-types";
import { useCredentials } from "../credentials";
import { useItems, useCollections } from "../etebase-helpers";
import { routeResolver } from "../App";
import TaskList from "./TaskList";
import Task from "./Task";
import LoadingIndicator from "../widgets/LoadingIndicator";
import TaskEdit from "./TaskEdit";
import PageNotFound, { PageNotFoundRoute } from "../PageNotFound";
import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction, PimFab, itemSave, itemDelete } from "../Pim/helpers";
import ItemChangeHistory from "../Pim/ItemChangeHistory";
const colType = "etebase.vtodo";
const decryptCollections = getDecryptCollectionsFunction(colType);
const decryptItems = getDecryptItemsFunction(colType, TaskType.parse);
export default function TasksMain() {
const [entries, setEntries] = React.useState<Map<string, Map<string, TaskType>>>();
const [cachedCollections, setCachedCollections] = React.useState<CachedCollection[]>();
const theme = useTheme();
const history = useHistory();
const etebase = useCredentials()!;
const collections = useCollections(etebase, colType);
const items = useItems(etebase, colType);
React.useEffect(() => {
if (!collections || !items) {
return;
}
(async () => {
const colEntries = await decryptCollections(collections);
const entries = await decryptItems(items);
setCachedCollections(colEntries);
setEntries(entries);
})();
}, [items, collections]);
if (!entries || !cachedCollections) {
return (
<LoadingIndicator />
);
}
async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise<void> {
const collection = collections!.find((x) => x.uid === collectionUid)!;
await itemSave(etebase, collection, items!, item, collectionUid, originalItem);
}
async function onItemDelete(item: PimType, collectionUid: string) {
const collection = collections!.find((x) => x.uid === collectionUid)!;
await itemDelete(etebase, collection, items!, item, collectionUid);
history.push(routeResolver.getRoute("pim.tasks"));
}
function onCancel() {
history.goBack();
}
const flatEntries = [];
for (const col of entries.values()) {
for (const item of col.values()) {
flatEntries.push(item);
}
}
const styles = {
button: {
marginLeft: theme.spacing(1),
},
leftIcon: {
marginRight: theme.spacing(1),
},
};
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.tasks")}
exact
>
<TaskList
entries={flatEntries}
collections={cachedCollections}
onItemClick={(item: TaskType) => history.push(
routeResolver.getRoute("pim.tasks._id", { itemUid: getItemNavigationUid(item) })
)}
onItemSave={onItemSave}
/>
<PimFab
onClick={() => history.push(
routeResolver.getRoute("pim.tasks.new")
)}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.tasks.new")}
exact
>
<TaskEdit
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.tasks._id.log")}
render={({ match }) => {
// We have this path outside because we don't want the item existing check
const [colUid, itemUid] = match.params.itemUid.split("|");
const cachedCollection = cachedCollections!.find((x) => x.collection.uid === colUid)!;
if (!cachedCollection) {
return (<PageNotFound />);
}
return (
<ItemChangeHistory collection={cachedCollection} itemUid={itemUid} />
);
}}
/>
<Route
path={routeResolver.getRoute("pim.tasks._id")}
render={({ match }) => {
const [colUid, itemUid] = match.params.itemUid.split("|");
const item = entries.get(colUid)?.get(itemUid);
if (!item) {
return (<PageNotFound />);
}
const collection = collections!.find((x) => x.uid === colUid)!;
const readOnly = collection.accessLevel === Etebase.CollectionAccessLevel.ReadOnly;
return (
<Switch>
<Route
path={routeResolver.getRoute("pim.tasks._id.edit")}
exact
>
<TaskEdit
key={itemUid}
initialCollection={item.collectionUid}
item={item}
collections={cachedCollections}
onSave={onItemSave}
onDelete={onItemDelete}
onCancel={onCancel}
history={history}
/>
</Route>
<Route
path={routeResolver.getRoute("pim.tasks._id")}
exact
>
<div style={{ textAlign: "right", marginBottom: 15 }}>
<Button
variant="contained"
style={styles.button}
onClick={() =>
history.push(routeResolver.getRoute("pim.tasks._id.log", { itemUid: getItemNavigationUid(item) }))
}
>
<IconChangeHistory style={styles.leftIcon} />
Change History
</Button>
<Button
color="secondary"
variant="contained"
disabled={readOnly}
style={{ ...styles.button, marginLeft: 15 }}
onClick={() =>
history.push(routeResolver.getRoute("pim.tasks._id.edit", { itemUid: getItemNavigationUid(item) }))
}
>
<IconEdit style={styles.leftIcon} />
Edit
</Button>
</div>
<Task item={item} />
</Route>
<PageNotFoundRoute />
</Switch>
);
}}
/>
<PageNotFoundRoute />
</Switch>
);
}

@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import ICAL from "ical.js";
import uuid from "uuid";
import TextField from "@material-ui/core/TextField";
import { TaskType, PimType, TaskStatusType } from "../pim-types";
import { CachedCollection } from "../Pim/helpers";
interface PropsType {
style: React.CSSProperties;
onSubmit: (item: PimType, journalUid: string, originalItem?: PimType) => void;
defaultCollection: CachedCollection;
}
function QuickAdd(props: PropsType) {
const [title, setTitle] = React.useState("");
const { style, onSubmit: save, defaultCollection } = props;
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setTitle(e.target.value);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const task = new TaskType(null);
task.uid = uuid.v4();
task.title = title;
task.status = TaskStatusType.NeedsAction;
task.lastModified = ICAL.Time.now();
save(task, defaultCollection.collection.uid, undefined);
setTitle("");
}
return (
<form onSubmit={handleSubmit} style={style}>
<TextField
label="Add a new task"
variant="outlined"
fullWidth
value={title}
onChange={handleChange}
/>
</form>
);
}
export default QuickAdd;

@ -0,0 +1,74 @@
import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import InboxIcon from "@material-ui/icons/Inbox";
import LabelIcon from "@material-ui/icons/LabelOutlined";
import TodayIcon from "@material-ui/icons/Today";
import { setSettings } from "../store/actions";
import { StoreState } from "../store";
import { List, ListItem, ListSubheader } from "../widgets/List";
import { TaskType, setTaskTags } from "../pim-types";
interface ListItemPropsType {
name: string | null;
icon?: React.ReactElement;
primaryText: string;
amount: number;
}
function SidebarListItem(props: ListItemPropsType) {
const { name, icon, primaryText, amount } = props;
const dispatch = useDispatch();
const taskSettings = useSelector((state: StoreState) => state.settings.taskSettings);
const { filterBy } = taskSettings;
const handleClick = () => {
dispatch(setSettings({ taskSettings: { ...taskSettings, filterBy: name } }));
};
return (
<ListItem
onClick={handleClick}
selected={name === filterBy}
leftIcon={icon}
rightIcon={<span style={{ width: "100%", textAlign: "right" }}>{(amount > 0) && amount}</span>}
primaryText={primaryText}
/>
);
}
export default React.memo(function Sidebar(props: { tasks: TaskType[] }) {
const { tasks } = props;
const amountDueToday = tasks.filter((x) => x.dueToday).length;
const tags = new Map<string, number>();
tasks.forEach((task) => task.tags.forEach((tag) => {
tags.set(tag, (tags.get(tag) ?? 0) + 1);
}));
// FIXME: ugly hack to support potential tags. Will be fixed very soon.
setTaskTags(Array.from(tags.keys()));
const tagsList = [...tags].sort(([a], [b]) => a.localeCompare(b)).map(([tag, amount]) => (
<SidebarListItem
key={tag}
name={`tag:${tag}`}
primaryText={tag}
icon={<LabelIcon />}
amount={amount}
/>
));
return (
<List dense>
<SidebarListItem name={null} primaryText="All" icon={<InboxIcon />} amount={tasks.length} />
<SidebarListItem name="today" primaryText="Due today" icon={<TodayIcon />} amount={amountDueToday} />
<ListSubheader>Tags</ListSubheader>
{tagsList}
</List>
);
});

@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import PimItemHeader from "../components/PimItemHeader";
import { formatDate, formatOurTimezoneOffset } from "../helpers";
import { TaskType } from "../pim-types";
class Task extends React.PureComponent {
public props: {
item?: TaskType;
};
public render() {
if (this.props.item === undefined) {
throw Error("Task should be defined!");
}
const { item } = this.props;
const style = {
content: {
padding: 15,
},
};
const timezone = this.props.item.timezone;
return (
<React.Fragment>
<PimItemHeader text={this.props.item.summary} backgroundColor={this.props.item.color}>
{item.startDate &&
<div>Start: {formatDate(item.startDate)} {timezone && <small>({formatOurTimezoneOffset()})</small>}</div>
}
{item.dueDate &&
<div>Due: {formatDate(item.dueDate)} {timezone && <small>({formatOurTimezoneOffset()})</small>}</div>
}
<br />
<div><u>{this.props.item.location}</u></div>
</PimItemHeader>
<div style={style.content}>
<p style={{ wordWrap: "break-word" }}>{this.props.item.description}</p>
{(this.props.item.attendees.length > 0) && (
<div>Attendees: {this.props.item.attendees.map((x) => (x.getFirstValue())).join(", ")}</div>)}
</div>
</React.Fragment>
);
}
}
export default Task;

@ -0,0 +1,498 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import FormGroup from "@material-ui/core/FormGroup";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import Switch from "@material-ui/core/Switch";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import FormControl from "@material-ui/core/FormControl";
import FormHelperText from "@material-ui/core/FormHelperText";
import InputLabel from "@material-ui/core/InputLabel";
import * as colors from "@material-ui/core/colors";
import FormLabel from "@material-ui/core/FormLabel";
import RadioGroup from "@material-ui/core/RadioGroup";
import Autocomplete from "@material-ui/lab/Autocomplete";
import IconDelete from "@material-ui/icons/Delete";
import IconCancel from "@material-ui/icons/Clear";
import IconSave from "@material-ui/icons/Save";
import DateTimePicker from "../widgets/DateTimePicker";
import ConfirmationDialog from "../widgets/ConfirmationDialog";
import TimezonePicker from "../widgets/TimezonePicker";
import Toast from "../widgets/Toast";
import * as uuid from "uuid";
import * as ICAL from "ical.js";
import { getCurrentTimezone, mapPriority } from "../helpers";
import { TaskType, TaskStatusType, timezoneLoadFromName, TaskPriorityType, TaskTags } from "../pim-types";
import { History } from "history";
import ColoredRadio from "../widgets/ColoredRadio";
import RRule, { RRuleOptions } from "../widgets/RRule";
import { CachedCollection } from "../Pim/helpers";
interface PropsType {
collections: CachedCollection[];
initialCollection?: string;
item?: TaskType;
onSave: (item: TaskType, collectionUid: string, originalItem?: TaskType) => Promise<void>;
onDelete: (item: TaskType, collectionUid: string) => void;
onCancel: () => void;
history: History<any>;
}
export default class TaskEdit extends React.PureComponent<PropsType> {
public state: {
uid: string;
title: string;
status: TaskStatusType;
priority: TaskPriorityType;
includeTime: boolean;
start?: Date;
due?: Date;
timezone: string | null;
rrule?: RRuleOptions;
location: string;
description: string;
tags: string[];
collectionUid: string;
error?: string;
showDeleteDialog: boolean;
};
constructor(props: PropsType) {
super(props);
this.state = {
uid: "",
title: "",
status: TaskStatusType.NeedsAction,
priority: TaskPriorityType.Undefined,
includeTime: false,
location: "",
description: "",
tags: [],
timezone: null,
collectionUid: "",
showDeleteDialog: false,
};
if (this.props.item !== undefined) {
const task = this.props.item;
this.state.uid = task.uid;
this.state.title = task.title ? task.title : "";
this.state.status = task.status ?? TaskStatusType.NeedsAction;
this.state.priority = task.priority ?? TaskPriorityType.Undefined;
if (task.startDate) {
this.state.includeTime = !task.startDate.isDate;
this.state.start = task.startDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
}
if (task.dueDate) {
this.state.due = task.dueDate.convertToZone(ICAL.Timezone.localTimezone).toJSDate();
}
const rrule = task.rrule;
if (rrule) {
this.state.rrule = rrule.toJSON() as any;
if (this.state.rrule && rrule.until) {
this.state.rrule.until = rrule.until;
}
}
this.state.location = task.location ? task.location : "";
this.state.description = task.description ? task.description : "";
this.state.timezone = task.timezone;
this.state.tags = task.tags;
} else {
this.state.uid = uuid.v4();
}
this.state.timezone = this.state.timezone || getCurrentTimezone();
if (props.initialCollection) {
this.state.collectionUid = props.initialCollection;
} else if (props.collections[0]) {
this.state.collectionUid = props.collections[0].collection.uid;
}
this.onSubmit = this.onSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.toggleTime = this.toggleTime.bind(this);
this.toggleRecurring = this.toggleRecurring.bind(this);
this.handleRRuleChange = this.handleRRuleChange.bind(this);
this.onDeleteRequest = this.onDeleteRequest.bind(this);
this.handleCloseToast = this.handleCloseToast.bind(this);
}
public handleChange(name: string, value: string | number | string[]) {
this.setState({
[name]: value,
});
}
public handleInputChange(event: React.ChangeEvent<any>) {
const name = event.target.name;
const value = event.target.value;
this.handleChange(name, value);
}
public toggleTime() {
this.setState({ includeTime: !this.state.includeTime });
}
public handleCloseToast(_event?: React.SyntheticEvent, reason?: string) {
if (reason === "clickaway") {
return;
}
this.setState({ error: "" });
}
public toggleRecurring() {
const value = this.state.rrule ? undefined : { freq: "WEEKLY", interval: 1 };
this.setState({ rrule: value });
}
public handleRRuleChange(rrule: RRuleOptions): void {
this.setState({ rrule: rrule });
}
public onSubmit(e: React.FormEvent<any>) {
e.preventDefault();
if (this.state.rrule && !(this.state.start || this.state.due)) {
this.setState({ error: "A recurring task must have either Hide Until or Due Date set!" });
return;
}
function fromDate(date: Date | undefined, includeTime: boolean) {
if (!date) {
return undefined;
}
const ret = ICAL.Time.fromJSDate(date, false);
if (includeTime) {
return ret;
} else {
const data = ret.toJSON();
data.isDate = true;
return ICAL.Time.fromData(data);
}
}
const startDate = fromDate(this.state.start, this.state.includeTime);
const dueDate = fromDate(this.state.due, this.state.includeTime);
if (startDate && dueDate) {
if (startDate.compare(dueDate) >= 0) {
this.setState({ error: "End time must be later than start time!" });
return;
}
}
const task = (this.props.item) ?
this.props.item.clone()
:
new TaskType(null)
;
task.uid = this.state.uid;
task.summary = this.state.title;
task.status = this.state.status;
task.priority = this.state.priority;
task.tags = this.state.tags;
if (startDate) {
task.startDate = startDate;
}
task.dueDate = dueDate;
if (this.state.rrule) {
task.rrule = new ICAL.Recur(this.state.rrule);
}
task.location = this.state.location;
task.description = this.state.description;
if (this.state.timezone) {
const timezone = timezoneLoadFromName(this.state.timezone);
if (timezone) {
if (task.startDate) {
task.startDate = task.startDate.convertToZone(timezone);
}
if (task.dueDate) {
task.dueDate = task.dueDate.convertToZone(timezone);
}
if (task.completionDate) {
task.completionDate = task.completionDate.convertToZone(timezone);
}
}
}
task.component.updatePropertyWithValue("last-modified", ICAL.Time.now());
this.props.onSave(task, this.state.collectionUid, this.props.item)
.then(() => {
const nextTask = task.finished && task.getNextOccurence();
if (nextTask) {
return this.props.onSave(nextTask, this.state.collectionUid);
} else {
return Promise.resolve();
}
})
.then(() => {
this.props.history.goBack();
})
.catch(() => {
this.setState({ error: "Could not save task" });
});
}
public onDeleteRequest() {
this.setState({
showDeleteDialog: true,
});
}
public render() {
const styles = {
form: {
},
fullWidth: {
width: "100%",
boxSizing: "border-box" as any,
marginTop: 16,
},
submit: {
marginTop: 40,
marginBottom: 20,
textAlign: "right" as any,
},
};
const recurring = this.props.item && this.props.item.isRecurring();
const differentTimezone = this.state.timezone && (this.state.timezone !== getCurrentTimezone()) && timezoneLoadFromName(this.state.timezone);
return (
<React.Fragment>
<h2>
{this.props.item ? "Edit Task" : "New Task"}
</h2>
{recurring && (
<div>
<span style={{ color: "red" }}>IMPORTANT: </span>
This is a recurring task, for now, only editing the whole series
(by editing the first instance) is supported.
</div>
)}
<Toast open={!!this.state.error} severity="error" onClose={this.handleCloseToast}>
ERROR! {this.state.error}
</Toast>
<form style={styles.form} onSubmit={this.onSubmit}>
<TextField
name="title"
placeholder="Enter title"
style={styles.fullWidth}
value={this.state.title}
onChange={this.handleInputChange}
/>
<FormControl disabled={this.props.item !== undefined} style={styles.fullWidth}>
<InputLabel>
Saving to
</InputLabel>
<Select
name="collectionUid"
value={this.state.collectionUid}
disabled={this.props.item !== undefined}
onChange={this.handleInputChange}
>
{this.props.collections.map((x) => (
<MenuItem key={x.collection.uid} value={x.collection.uid}>{x.metadata.name}</MenuItem>
))}
</Select>
</FormControl>
<FormControl style={styles.fullWidth}>
<InputLabel>
Status
</InputLabel>
<Select
name="status"
value={this.state.status}
onChange={this.handleInputChange}
>
<MenuItem value={TaskStatusType.NeedsAction}>Needs action</MenuItem>
<MenuItem value={TaskStatusType.InProcess}>In progress</MenuItem>
<MenuItem value={TaskStatusType.Completed}>Completed</MenuItem>
<MenuItem value={TaskStatusType.Cancelled}>Cancelled</MenuItem>
</Select>
</FormControl>
<FormControl style={styles.fullWidth}>
<FormLabel>Priority</FormLabel>
<RadioGroup
row
value={mapPriority(this.state.priority)}
onChange={(e) => this.handleChange("priority", Number(e.target.value))}
>
<ColoredRadio value={TaskPriorityType.Undefined} label="None" color={colors.grey[600]} />
<ColoredRadio value={TaskPriorityType.Low} label="Low" color={colors.blue[600]} />
<ColoredRadio value={TaskPriorityType.Medium} label="Medium" color={colors.orange[600]} />
<ColoredRadio value={TaskPriorityType.High} label="High" color={colors.red[600]} />
</RadioGroup>
</FormControl>
<FormControl style={styles.fullWidth}>
<FormHelperText>Hide until</FormHelperText>
<DateTimePicker
dateOnly={!this.state.includeTime}
placeholder="Hide until"
value={this.state.start}
onChange={(date?: Date) => this.setState({ start: date })}
/>
{differentTimezone && this.state.start && (
<FormHelperText>{ICAL.Time.fromJSDate(this.state.start, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
)}
</FormControl>
<FormControl style={styles.fullWidth}>
<FormHelperText>Due</FormHelperText>
<DateTimePicker
dateOnly={!this.state.includeTime}
placeholder="Due"
value={this.state.due}
onChange={(date?: Date) => this.setState({ due: date })}
/>
{differentTimezone && this.state.due && (
<FormHelperText>{ICAL.Time.fromJSDate(this.state.due, false).convertToZone(differentTimezone!).toJSDate().toString()}</FormHelperText>
)}
</FormControl>
<FormGroup style={styles.fullWidth}>
<FormControlLabel
control={
<Switch
name="includeTime"
checked={this.state.includeTime}
onChange={this.toggleTime}
color="primary"
/>
}
label="Include time"
/>
</FormGroup>
{(this.state.includeTime) && (
<TimezonePicker style={styles.fullWidth} value={this.state.timezone} onChange={(zone) => this.setState({ timezone: zone })} />
)}
<FormGroup>
<FormControlLabel
control={
<Switch
name="recurring"
checked={!!this.state.rrule}
onChange={this.toggleRecurring}
color="primary"
/>
}
label="Recurring"
/>
</FormGroup>
{this.state.rrule &&
<RRule
onChange={this.handleRRuleChange}
rrule={this.state.rrule ? this.state.rrule : { freq: "DAILY", interval: 1 }}
/>
}
<TextField
name="location"
placeholder="Add location"
style={styles.fullWidth}
value={this.state.location}
onChange={this.handleInputChange}
/>
<TextField
name="description"
placeholder="Add description"
multiline
style={styles.fullWidth}
value={this.state.description}
onChange={this.handleInputChange}
/>
<Autocomplete
style={styles.fullWidth}
freeSolo
multiple
options={TaskTags}
value={this.state.tags}
onChange={(_e, value) => this.handleChange("tags", value)}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
label="Tags"
fullWidth
/>
)}
/>
<div style={styles.submit}>
<Button
variant="contained"
onClick={this.props.onCancel}
>
<IconCancel style={{ marginRight: 8 }} />
Cancel
</Button>
{this.props.item &&
<Button
variant="contained"
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
onClick={this.onDeleteRequest}
>
<IconDelete style={{ marginRight: 8 }} />
Delete
</Button>
}
<Button
type="submit"
variant="contained"
color="secondary"
style={{ marginLeft: 15 }}
>
<IconSave style={{ marginRight: 8 }} />
Save
</Button>
</div>
</form>
<ConfirmationDialog
title="Delete Confirmation"
labelOk="Delete"
open={this.state.showDeleteDialog}
onOk={() => this.props.onDelete(this.props.item!, this.props.initialCollection!)}
onCancel={() => this.setState({ showDeleteDialog: false })}
>
Are you sure you would like to delete this task?
</ConfirmationDialog>
</React.Fragment>
);
}
}

@ -0,0 +1,235 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { List } from "../widgets/List";
import { TaskType, PimType, TaskStatusType } from "../pim-types";
import Divider from "@material-ui/core/Divider";
import Grid from "@material-ui/core/Grid";
import { useTheme, makeStyles } from "@material-ui/core/styles";
import { useSelector, useDispatch } from "react-redux";
import Fuse from "fuse.js";
import TaskListItem from "./TaskListItem";
import Sidebar from "./Sidebar";
import Toolbar from "./Toolbar";
import QuickAdd from "./QuickAdd";
import { StoreState } from "../store";
import { formatDate } from "../helpers";
import { CachedCollection } from "../Pim/helpers";
import { pushMessage } from "../store/actions";
function sortCompleted(a: TaskType, b: TaskType) {
return (!!a.finished === !!b.finished) ? 0 : (a.finished) ? 1 : -1;
}
function sortLastModifiedDate(aIn: TaskType, bIn: TaskType) {
const a = aIn.lastModified?.toJSDate() ?? new Date(0);
const b = bIn.lastModified?.toJSDate() ?? new Date(0);
return (a > b) ? -1 : (a < b) ? 1 : 0;
}
function sortDueDate(aIn: TaskType, bIn: TaskType) {
const impossiblyLargeDate = 8640000000000000;
const a = aIn.dueDate?.toJSDate() ?? new Date(impossiblyLargeDate);
const b = bIn.dueDate?.toJSDate() ?? new Date(impossiblyLargeDate);
return (a < b) ? -1 : (a > b) ? 1 : 0;
}
function sortPriority(aIn: TaskType, bIn: TaskType) {
// Intentionally converts 0/undefined to 10 (1 more than lowest priority) to sort to back of the list
const a = aIn.priority || 10;
const b = bIn.priority || 10;
return a - b;
}
function sortTitle(aIn: TaskType, bIn: TaskType) {
const a = aIn.title ?? "";
const b = bIn.title ?? "";
return a.localeCompare(b);
}
function getSortFunction(sortOrder: string) {
const sortFunctions: (typeof sortTitle)[] = [sortCompleted];
switch (sortOrder) {
case "smart":
sortFunctions.push(sortPriority);
sortFunctions.push(sortDueDate);
sortFunctions.push(sortTitle);
break;
case "dueDate":
sortFunctions.push(sortDueDate);
break;
case "priority":
sortFunctions.push(sortPriority);
sortFunctions.push(sortDueDate);
break;
case "title":
sortFunctions.push(sortTitle);
break;
case "lastModifiedDate":
// Do nothing because it's the last sort function anyway
break;
}
sortFunctions.push(sortLastModifiedDate);
return (a: TaskType, b: TaskType) => {
for (const sortFunction of sortFunctions) {
const ret = sortFunction(a, b);
if (ret !== 0) {
return ret;
}
}
return 0;
};
}
const useStyles = makeStyles((theme) => ({
topBar: {
backgroundColor: theme.palette.primary[500],
},
}));
interface PropsType {
entries: TaskType[];
collections: CachedCollection[];
onItemClick: (entry: TaskType) => void;
onItemSave: (item: PimType, journalUid: string, originalItem?: PimType) => Promise<void>;
}
export default function TaskList(props: PropsType) {
const [showCompleted, setShowCompleted] = React.useState(false);
const [showHidden, setShowHidden] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState("");
const settings = useSelector((state: StoreState) => state.settings.taskSettings);
const { filterBy, sortBy } = settings;
const dispatch = useDispatch();
const theme = useTheme();
const classes = useStyles();
const { onItemClick } = props;
const handleToggleComplete = async (task: TaskType, completed: boolean) => {
const clonedTask = task.clone();
clonedTask.status = completed ? TaskStatusType.Completed : TaskStatusType.NeedsAction;
const nextTask = completed ? task.getNextOccurence() : null;
try {
await props.onItemSave(clonedTask, task.collectionUid!, task);
if (nextTask) {
dispatch(pushMessage({ message: `${nextTask.title} rescheduled for ${formatDate(nextTask.startDate ?? nextTask.dueDate)}`, severity: "success" }));
}
} catch (_e) {
dispatch(pushMessage({ message: "Failed to save changes. This may be due to a network error.", severity: "error" }));
}
};
const potentialEntries = React.useMemo(
() => {
if (searchTerm) {
const result = new Fuse(props.entries, {
shouldSort: true,
threshold: 0.6,
maxPatternLength: 32,
minMatchCharLength: 2,
keys: [
"title",
"desc",
],
}).search(searchTerm);
return result.map((x) => x.item);
} else {
return props.entries.filter((x) => (showCompleted || !x.finished) && (showHidden || !x.hidden));
}
},
[showCompleted, props.entries, searchTerm, showHidden]
);
let entries;
const tagPrefix = "tag:";
if (filterBy?.startsWith(tagPrefix)) {
const tag = filterBy.slice(tagPrefix.length);
entries = potentialEntries.filter((x) => x.tags.includes(tag));
} else if (filterBy === "today") {
entries = potentialEntries.filter((x) => x.dueToday);
} else {
entries = potentialEntries;
}
const subEntriesMap = new Map<string, TaskType[]>();
entries = entries.filter((x) => {
const relatedTo = x.relatedTo;
if (relatedTo) {
const cur = subEntriesMap.get(relatedTo) ?? [];
cur.push(x);
subEntriesMap.set(relatedTo, cur);
return false;
}
return true;
});
function taskListItemFromTask(entry: TaskType) {
const uid = entry.uid;
return (
<TaskListItem
key={uid}
entry={entry}
nestedItems={subEntriesMap.get(uid)?.map(taskListItemFromTask)}
onClick={onItemClick}
onToggleComplete={handleToggleComplete}
/>
);
}
const sortedEntries = entries.sort(getSortFunction(sortBy));
const itemList = sortedEntries.map(taskListItemFromTask);
return (
<Grid container spacing={4}>
<Grid item xs={3} className={classes.topBar}>
{/* spacer */}
</Grid>
<Grid item xs={9} className={classes.topBar}>
<Toolbar
defaultCollection={props.collections?.[0]}
onItemSave={props.onItemSave}
showCompleted={showCompleted}
setShowCompleted={setShowCompleted}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
showHidden={showHidden}
setShowHidden={setShowHidden}
/>
</Grid>
<Grid item xs={3} style={{ borderRight: `1px solid ${theme.palette.divider}` }}>
<Sidebar tasks={potentialEntries} />
</Grid>
<Grid item xs>
{props.collections?.[0] && <QuickAdd style={{ flexGrow: 1, marginRight: "0.75em" }} onSubmit={props.onItemSave} defaultCollection={props.collections?.[0]} />}
<Divider style={{ marginTop: "1em" }} />
<List>
{itemList}
</List>
</Grid>
</Grid>
);
}

@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { TaskType, TaskPriorityType } from "../pim-types";
import { ListItem } from "../widgets/List";
import Checkbox from "@material-ui/core/Checkbox";
import CheckBoxOutlineBlankIcon from "@material-ui/icons/CheckBoxOutlineBlank";
import * as colors from "@material-ui/core/colors";
import Chip from "@material-ui/core/Chip";
import { mapPriority, formatDate } from "../helpers";
const checkboxColor = {
[TaskPriorityType.Undefined]: colors.grey[600],
[TaskPriorityType.Low]: colors.blue[600],
[TaskPriorityType.Medium]: colors.orange[600],
[TaskPriorityType.High]: colors.red[600],
};
const TagsList = React.memo((props: { tags: string[] }) => (
<ul>
{props.tags.map((tag, i) => tag && <Chip
key={i}
color="secondary"
size="small"
label={tag}
style={{ marginRight: "0.75em" }}
component="li"
/>)}
</ul>));
interface PropsType {
entry: TaskType;
nestedItems?: React.ReactNode[];
onClick: (task: TaskType) => void;
onToggleComplete: (task: TaskType, completed: boolean) => void;
}
export default React.memo(function TaskListItem(props: PropsType) {
const {
entry: task,
nestedItems,
onClick,
onToggleComplete,
} = props;
const title = task.title;
const dueDateText = task.dueDate ? `Due ${formatDate(task.dueDate)}` : "";
const freqText = task.rrule ? `(repeats ${task.rrule.freq.toLowerCase()})` : "";
const secondaryText = `${dueDateText} ${freqText}`;
return (
<ListItem
primaryText={title}
secondaryText={secondaryText}
secondaryTextColor={task.overdue ? "error" : "textSecondary"}
nestedItems={nestedItems}
onClick={() => onClick(task)}
leftIcon={
<Checkbox
onClick={(e) => e.stopPropagation()}
onChange={(_e, checked) => onToggleComplete(task, checked)}
checked={task.finished}
icon={<CheckBoxOutlineBlankIcon style={{ color: checkboxColor[mapPriority(task.priority)] }} />}
/>
}
rightIcon={<TagsList tags={task.tags} />}
/>
);
});

@ -0,0 +1,164 @@
import * as React from "react";
import Switch from "@material-ui/core/Switch";
import IconButton from "@material-ui/core/IconButton";
import MoreVertIcon from "@material-ui/icons/MoreVert";
import MenuItem from "@material-ui/core/MenuItem";
import SortIcon from "@material-ui/icons/Sort";
import SearchIcon from "@material-ui/icons/Search";
import TextField from "@material-ui/core/TextField";
import { makeStyles } from "@material-ui/core/styles";
import { Transition } from "react-transition-group";
import InputAdornment from "@material-ui/core/InputAdornment";
import { PimType } from "../pim-types";
import { useSelector, useDispatch } from "react-redux";
import { setSettings } from "../store/actions";
import { StoreState } from "../store";
import Menu from "../widgets/Menu";
import { ListItemText, ListItemSecondaryAction } from "@material-ui/core";
import { CachedCollection } from "../Pim/helpers";
const transitionTimeout = 300;
const transitionStyles = {
entering: { visibility: "visible", width: "100%", overflow: "hidden" },
entered: { visibility: "visible", width: "100%" },
exiting: { visibility: "visible", width: "0%", overflow: "hidden" },
exited: { visibility: "hidden", width: "0%" },
};
const useStyles = makeStyles((theme) => ({
button: {
marginRight: theme.spacing(1),
},
textField: {
transition: `width ${transitionTimeout}ms`,
marginRight: theme.spacing(1),
},
}));
interface PropsType {
defaultCollection: CachedCollection;
onItemSave: (item: PimType, journalUid: string, originalItem?: PimType) => Promise<void>;
showCompleted: boolean;
showHidden: boolean;
setShowCompleted: (completed: boolean) => void;
setShowHidden: (hidden: boolean) => void;
searchTerm: string;
setSearchTerm: (term: string) => void;
}
export default function Toolbar(props: PropsType) {
const { showCompleted, setShowCompleted, searchTerm, setSearchTerm, showHidden, setShowHidden } = props;
const [sortAnchorEl, setSortAnchorEl] = React.useState<null | HTMLElement>(null);
const [optionsAnchorEl, setOptionsAnchorEl] = React.useState<null | HTMLElement>(null);
const showSearchField = true;
const classes = useStyles();
const dispatch = useDispatch();
const taskSettings = useSelector((state: StoreState) => state.settings.taskSettings);
const { sortBy } = taskSettings;
const handleSortChange = (sort: string) => {
dispatch(setSettings({ taskSettings: { ...taskSettings, sortBy: sort } }));
setSortAnchorEl(null);
};
const SortMenuItem = React.forwardRef(function SortMenuItem(props: { name: string, label: string }, ref) {
return (
<MenuItem innerRef={ref} selected={sortBy === props.name} onClick={() => handleSortChange(props.name)}>{props.label}</MenuItem>
);
});
return (
<div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center" }}>
<Transition in={showSearchField} timeout={transitionTimeout}>
{(state) => (
<TextField
fullWidth
placeholder="Search"
value={searchTerm}
color="secondary"
variant="standard"
className={classes.textField}
style={transitionStyles[state]}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
)}
</Transition>
<div className={classes.button}>
<IconButton
size="small"
title="Sort"
aria-label="Sort"
aria-controls="sort-menu"
aria-haspopup="true"
onClick={(e) => setSortAnchorEl(e.currentTarget)}
>
<SortIcon />
</IconButton>
<Menu
id="sort-menu"
anchorEl={sortAnchorEl}
keepMounted
open={!!sortAnchorEl}
onClose={() => setSortAnchorEl(null)}
>
<SortMenuItem name="smart" label="Smart" />
<SortMenuItem name="dueDate" label="Due Date" />
<SortMenuItem name="priority" label="Priority" />
<SortMenuItem name="title" label="Title" />
<SortMenuItem name="lastModifiedDate" label="Last Modified" />
</Menu>
</div>
<div className={classes.button}>
<IconButton
size="small"
title="Options"
aria-label="Options"
aria-controls="options-menu"
aria-haspopup="true"
onClick={(e) => setOptionsAnchorEl(e.currentTarget)}
>
<MoreVertIcon />
</IconButton>
<Menu
id="options-menu"
anchorEl={optionsAnchorEl}
keepMounted
open={!!optionsAnchorEl}
onClose={() => setOptionsAnchorEl(null)}
>
<MenuItem>
<ListItemText style={{ marginRight: "1.5em" }}>Show completed</ListItemText>
<ListItemSecondaryAction>
<Switch checked={showCompleted} onChange={(_e, checked) => setShowCompleted(checked)} edge="end" />
</ListItemSecondaryAction>
</MenuItem>
<MenuItem>
<ListItemText style={{ marginRight: "1.5em" }}>Show hidden</ListItemText>
<ListItemSecondaryAction>
<Switch checked={showHidden} onChange={(_e, checked) => setShowHidden(checked)} edge="end" />
</ListItemSecondaryAction>
</MenuItem>
</Menu>
</div>
</div>
);
}

@ -0,0 +1,171 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as Etebase from "etebase";
import { Redirect, useLocation } from "react-router";
import Button from "@material-ui/core/Button";
import Alert from "@material-ui/lab/Alert";
import { routeResolver } from "./App";
import Container from "./widgets/Container";
import LoadingIndicator from "./widgets/LoadingIndicator";
import Wizard, { WizardNavigationBar, PagePropsType } from "./widgets/Wizard";
import { SyncManager } from "./sync/SyncManager";
import { store } from "./store";
import { useCredentials } from "./credentials";
import wizardWelcome from "./images/wizard-welcome.svg";
import wizardCreate from "./images/wizard-create.svg";
interface LocationState {
from: {
pathname: string;
};
}
const wizardPages = [
(props: PagePropsType) => (
<>
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center" }}>
<h2 style={{ textAlign: "center" }}>Welcome to EteSync!</h2>
<p style={{ textAlign: "center" }}>
Please follow these few quick steps to help you get started.
</p>
<img src={wizardWelcome} style={{ maxWidth: "30em", marginTop: "2em" }} />
</div>
<WizardNavigationBar {...props} />
</>
),
(props: PagePropsType) => (
<SetupCollectionsPage {...props} />
),
];
function SetupCollectionsPage(props: PagePropsType) {
const etebase = useCredentials()!;
const [error, setError] = React.useState<Error>();
const [loading, setLoading] = React.useState(false);
async function onNext() {
setLoading(true);
try {
const colMgr = etebase.getCollectionManager();
const types = [
["etebase.vcard", "My Contacts"],
["etebase.vevent", "My Calendar"],
["etebase.vtodo", "My Tasks"],
];
for (const [type, name] of types) {
const meta: Etebase.ItemMetadata = {
name,
mtime: (new Date()).getTime(),
};
const collection = await colMgr.create(type, meta, "");
await colMgr.upload(collection);
}
props.next?.();
} catch (e) {
setError(e);
} finally {
setLoading(false);
}
}
const next = (loading) ? undefined : onNext;
return (
<>
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center" }}>
<h2 style={{ textAlign: "center" }}>Setup Collections</h2>
<p style={{ textAlign: "center", maxWidth: "50em" }}>
In order to start using EteSync you need to create collections to store your data. Clicking <i>Finish</i> below will create a default calendar, address book and a task list for you.
</p>
{(loading) ? (
<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />
) : (error) ? (
<>
<Alert severity="error">{error.message}</Alert>
<Button
variant="contained"
color="primary"
onClick={props.next}
>
Skip
</Button>
</>
) : (
<img src={wizardCreate} style={{ maxWidth: "30em", marginTop: "2em" }} />
)}
</div>
<WizardNavigationBar {...props} next={next} />
</>
);
}
export default function WizardPage() {
const [tryCount, setTryCount] = React.useState(0);
const [ranWizard, setRanWizard] = React.useState(false);
const [syncError, setSyncError] = React.useState<Error>();
const etebase = useCredentials();
const location = useLocation();
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
setLoading(true);
setSyncError(undefined);
(async () => {
const syncManager = SyncManager.getManager(etebase!);
const sync = syncManager.sync(true);
try {
await sync;
const cachedCollection = store.getState().cache.collections;
// XXX new account - though should change test to see if there are any PIM types
if (cachedCollection.size > 0) {
setRanWizard(true);
}
} catch (e) {
setSyncError(e);
}
setLoading(false);
})();
}, [tryCount]);
if (syncError) {
return (
<Container>
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "center", alignItems: "center" }}>
<h2 style={{ textAlign: "center" }}>Important</h2>
<p style={{ textAlign: "center" }}>
{syncError?.message}
</p>
<Button
variant="contained"
color="primary"
onClick={() => setTryCount(tryCount + 1)}
>
Retry
</Button>
</div>
</Container>
);
}
if (loading) {
return (<LoadingIndicator style={{ display: "block", margin: "40px auto" }} />);
}
if (!ranWizard) {
return (
<Wizard pages={wizardPages} onFinish={() => setRanWizard(true)} style={{ display: "flex", flexDirection: "column", flex: 1 }} />
);
}
const { from } = location.state as LocationState || { from: { pathname: routeResolver.getRoute("home") } };
return (
<Redirect to={from.pathname} />
);
}

@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { IntegrityError } from "etebase";
import PrettyError from "../widgets/PrettyError";
interface PropsType {
children: React.ReactNode | React.ReactNode[];
}
class ErrorBoundary extends React.Component<PropsType> {
public state: {
error?: Error;
};
constructor(props: PropsType) {
super(props);
this.state = { };
}
public componentDidCatch(error: Error, _info: any) {
this.setState({ error });
}
public render() {
const { error } = this.state;
if (error) {
if (error instanceof IntegrityError) {
return (
<div>
<h2>Integrity Error</h2>
<p>
Please log out from the menu, refresh the page and try again, and if the problem persists, contact support.
</p>
<pre>
{error.message}
</pre>
</div>
);
}
}
if (error) {
return (
<div>
<h2>Something went wrong!</h2>
<PrettyError error={this.state.error} />
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import moment from "moment";
import * as Etebase from "etebase";
import { AutoSizer, List as VirtualizedList } from "react-virtualized";
import { List, ListItem } from "../widgets/List";
import IconEdit from "@material-ui/icons/Edit";
import IconDelete from "@material-ui/icons/Delete";
import IconError from "@material-ui/icons/Error";
import { TaskType, EventType, ContactType, parseString } from "../pim-types";
export interface CachedItem {
item: Etebase.Item;
metadata: Etebase.ItemMetadata;
content: string;
}
interface PropsType {
items: CachedItem[];
onItemClick: (item: CachedItem) => void;
}
export default function GenericChangeHistory(props: PropsType) {
const entriesList = props.items.sort((a_, b_) => {
const a = a_.metadata.mtime ?? 0;
const b = b_.metadata.mtime ?? 0;
return a - b;
});
const onItemClick = props.onItemClick;
const rowRenderer = (params: { index: number, key: string, style: React.CSSProperties }) => {
const { key, index, style } = params;
const cacheItem = entriesList[entriesList.length - index - 1]!;
let comp;
try {
comp = parseString(cacheItem.content);
} catch (e) {
const icon = (<IconError style={{ color: "red" }} />);
return (
<ListItem
key={key}
style={style}
leftIcon={icon}
primaryText="Failed parsing item"
secondaryText="Unknown"
onClick={() => onItemClick(cacheItem)}
/>
);
}
let icon;
if (!cacheItem.item.isDeleted) {
icon = (<IconEdit style={{ color: "#16B14B" }} />);
} else {
icon = (<IconDelete style={{ color: "#F20C0C" }} />);
}
let name;
if (comp.name === "vcalendar") {
if (EventType.isEvent(comp)) {
const vevent = EventType.fromVCalendar(comp);
name = vevent.summary;
} else {
const vtodo = TaskType.fromVCalendar(comp);
name = vtodo.summary;
}
} else if (comp.name === "vcard") {
const vcard = new ContactType(comp);
name = vcard.fn;
} else {
name = "Error processing entry";
}
const mtime = (cacheItem.metadata.mtime) ? moment(cacheItem.metadata.mtime) : undefined;
return (
<ListItem
key={key}
style={style}
leftIcon={icon}
primaryText={name}
secondaryText={mtime && mtime.format("llll")}
onClick={() => onItemClick(cacheItem)}
/>
);
};
return (
<List style={{ height: "100%" }}>
{(entriesList.length > 0) ? (
<AutoSizer>
{({ height, width }) => (
<VirtualizedList
width={width}
height={height}
rowCount={entriesList.length}
rowHeight={56}
rowRenderer={rowRenderer}
/>
)}
</AutoSizer>
) : (
<ListItem
primaryText="No entries found"
/>
)}
</List>
);
}

@ -0,0 +1,175 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Button from "@material-ui/core/Button";
import TextField from "@material-ui/core/TextField";
import FormGroup from "@material-ui/core/FormGroup";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import Switch from "@material-ui/core/Switch";
import ExternalLink from "../widgets/ExternalLink";
import PasswordField from "../widgets/PasswordField";
import * as C from "../constants";
import LoadingIndicator from "../widgets/LoadingIndicator";
import Alert from "@material-ui/lab/Alert";
interface FormErrors {
errorEmail?: string;
errorPassword?: string;
errorEncryptionPassword?: string;
errorServer?: string;
}
interface PropsType {
onSubmit: (username: string, password: string, serviceApiUrl?: string) => void;
loading?: boolean;
error?: Error;
}
export default function LoginForm(props: PropsType) {
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
const [server, setServer] = React.useState("");
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [errors, setErrors] = React.useState<FormErrors>({});
function generateEncryption(e: React.FormEvent<any>) {
e.preventDefault();
const errors: FormErrors = {};
const fieldRequired = "This field is required!";
if (!username) {
errors.errorEmail = fieldRequired;
} else if (username.includes("@")) {
errors.errorEmail = "Please use your username (not email)";
}
if (!password) {
errors.errorPassword = fieldRequired;
}
if (process.env.NODE_ENV !== "development") {
if (showAdvanced && !server.startsWith("https://")) {
errors.errorServer = "Server URI must start with https://";
}
}
if (Object.keys(errors).length) {
setErrors(errors);
return;
} else {
setErrors({});
}
props.onSubmit(username, password, (showAdvanced) ? server : undefined);
}
const styles = {
form: {
},
forgotPassword: {
paddingTop: 20,
},
infoAlert: {
marginTop: 20,
},
textField: {
marginTop: 20,
width: "18em",
},
submit: {
marginTop: 40,
textAlign: "right" as any,
},
};
function handleInputChange(func: (value: string) => void) {
return (event: React.ChangeEvent<any>) => {
func(event.target.value);
};
}
let advancedSettings = null;
if (showAdvanced) {
advancedSettings = (
<React.Fragment>
<TextField
type="url"
style={styles.textField}
error={!!errors.errorServer}
helperText={errors.errorServer}
label="Server"
value={server}
onChange={handleInputChange(setServer)}
/>
<br />
</React.Fragment>
);
}
if (props.loading) {
return (
<div style={{ textAlign: "center" }}>
<LoadingIndicator />
<p>Deriving encryption data...</p>
</div>
);
}
return (
<React.Fragment>
<form style={styles.form} onSubmit={generateEncryption}>
<TextField
type="text"
style={styles.textField}
error={!!errors.errorEmail}
helperText={errors.errorEmail}
label="Username"
value={username}
onChange={handleInputChange(setUsername)}
/>
<br />
<PasswordField
style={styles.textField}
error={!!errors.errorPassword}
helperText={errors.errorPassword}
label="Password"
name="password"
value={password}
onChange={handleInputChange(setPassword)}
/>
<div style={styles.forgotPassword}>
<ExternalLink href={C.forgotPassword}>Forgot password?</ExternalLink>
</div>
<FormGroup>
<FormControlLabel
control={
<Switch
color="primary"
checked={showAdvanced}
onChange={() => setShowAdvanced(!showAdvanced)}
/>
}
label="Advanced settings"
/>
</FormGroup>
{advancedSettings}
{props.error && (
<Alert severity="error" style={styles.infoAlert}>{props.error.message}</Alert>
)}
<div style={styles.submit}>
<Button
variant="contained"
type="submit"
color="secondary"
disabled={props.loading}
>
{props.loading ? "Loading…" : "Log In"}
</Button>
</div>
</form>
</React.Fragment>
);
}

@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Color from "color";
import { Theme, withTheme } from "@material-ui/core/styles";
export default withTheme((props: {text: string, backgroundColor?: string, children?: any, rightItem?: React.ReactNode, theme: Theme}) => {
const backgroundColor = props.backgroundColor ?? props.theme.palette.secondary.main;
const foregroundColor = props.theme.palette.getContrastText(Color(backgroundColor).rgb().string());
const style = {
header: {
backgroundColor,
color: foregroundColor,
padding: 15,
display: "flex",
justifyContent: "space-between",
},
headerText: {
marginTop: 10,
marginBottom: 10,
},
};
return (
<div style={style.header}>
<div>
<h2 style={style.headerText}>{props.text}</h2>
{props.children}
</div>
{props.rightItem}
</div>
);
});

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
export const appName = "EteSync";
export const defaultServerUrl = process.env.REACT_APP_DEFAULT_API_PATH ?? "https://api.etebase.com/partner/etesync/";
export const homePage = "https://www.etesync.com/";
export const faq = homePage + "faq/";
export const pricing = homePage + "pricing/";
export const getApps = homePage + "get-apps/";
export const terms = homePage + "tos/";
export const sourceCode = "https://github.com/etesync/etesync-web";
export const reportIssue = sourceCode + "/issues";
export const forgotPassword = "https://www.etesync.com/faq/#forgot-password";

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: © 2017 Etebase Authors
// SPDX-License-Identifier: AGPL-3.0-only
import { useSelector } from "react-redux";
import { createSelector } from "reselect";
import * as Etebase from "etebase";
import * as store from "./store";
import { usePromiseMemo } from "./helpers";
export const credentialsSelector = createSelector(
(state: store.StoreState) => state.credentials.storedSession,
(storedSession) => {
if (storedSession) {
return Etebase.Account.restore(storedSession);
} else {
return Promise.resolve(null);
}
}
);
export function useCredentials() {
const credentialsPromise = useSelector(credentialsSelector);
return usePromiseMemo(credentialsPromise, [credentialsPromise]);
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,77 @@
import memoize from "memoizee";
import * as Etebase from "etebase";
import { useSelector } from "react-redux";
import { StoreState } from "./store";
import { CacheCollectionsData, CacheItems, CacheItemsData } from "./store/reducers";
import { usePromiseMemo } from "./helpers";
export const getCollections = memoize(async function (cachedCollections: CacheCollectionsData, etebase: Etebase.Account) {
const colMgr = getCollectionManager(etebase);
const ret: Etebase.Collection[] = [];
for (const cached of cachedCollections.values()) {
ret.push(colMgr.cacheLoad(cached));
}
return ret;
}, { length: 1 });
export const getCollectionsByType = memoize(async function (cachedCollections: CacheCollectionsData, colType: string, etebase: Etebase.Account) {
const collections = await getCollections(cachedCollections, etebase);
const ret: Etebase.Collection[] = [];
for (const col of collections) {
const collectionType = col.getCollectionType();
if (collectionType === colType) {
ret.push(col);
}
}
return ret;
}, { length: 2 });
export const getItems = memoize(async function (cachedItems: CacheItems, itemMgr: Etebase.ItemManager) {
const ret = new Map<string, Etebase.Item>();
for (const cached of cachedItems.values()) {
const item = itemMgr.cacheLoad(cached);
ret.set(item.uid, item);
}
return ret;
}, { length: 1 });
export const getItemsByType = memoize(async function (cachedCollections: CacheCollectionsData, cachedItems: CacheItemsData, colType: string, etebase: Etebase.Account) {
const colMgr = getCollectionManager(etebase);
const collections = await getCollectionsByType(cachedCollections, colType, etebase);
const ret = new Map<string, Map<string, Etebase.Item>>();
for (const col of collections) {
const itemMgr = colMgr.getItemManager(col);
const cachedColItems = cachedItems.get(col.uid);
if (cachedColItems) {
const items = await getItems(cachedColItems, itemMgr);
ret.set(col.uid, items);
}
}
return ret;
}, { length: 3 });
export const getCollectionManager = memoize(function (etebase: Etebase.Account) {
return etebase.getCollectionManager();
});
// React specific stuff
export function useCollections(etebase: Etebase.Account, colType?: string) {
const cachedCollections = useSelector((state: StoreState) => state.cache.collections);
return usePromiseMemo(
(colType) ?
getCollectionsByType(cachedCollections, colType, etebase) :
getCollections(cachedCollections, etebase),
[etebase, cachedCollections, colType]
);
}
export function useItems(etebase: Etebase.Account, colType: string) {
const cachedCollections = useSelector((state: StoreState) => state.cache.collections);
const cachedItems = useSelector((state: StoreState) => state.cache.items);
return usePromiseMemo(
getItemsByType(cachedCollections, cachedItems, colType, etebase),
[etebase, cachedCollections, cachedItems, colType]
);
}

@ -0,0 +1,213 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as ICAL from "ical.js";
import moment from "moment";
import { TaskPriorityType } from "./pim-types";
// Generic handling of input changes
export function handleInputChange(self: React.Component, part?: string) {
return (event: React.ChangeEvent<any>) => {
const name = event.target.name;
const value = event.target.value;
let newState;
if (event.target.type === "checkbox") {
newState = {
[name]: event.target.checked,
};
} else {
newState = {
[name]: value,
};
}
if (part === undefined) {
self.setState(newState);
} else {
self.setState({
[part]: {
...self.state[part],
...newState,
},
});
}
};
}
export function insertSorted<T>(array: T[] = [], newItem: T, key: string) {
if (array.length === 0) {
return [newItem];
}
for (let i = 0, len = array.length; i < len; i++) {
if (newItem[key] < array[i][key]) {
array.splice(i, 0, newItem);
return array;
}
}
array.push(newItem);
return array;
}
const allDayFormat = "dddd, LL";
const fullFormat = "LLLL";
export function formatDate(date: ICAL.Time) {
const mDate = moment(date.toJSDate());
if (date.isDate) {
return mDate.format(allDayFormat);
} else {
return mDate.format(fullFormat);
}
}
export function formatDateRange(start: ICAL.Time, end: ICAL.Time) {
const mStart = moment(start.toJSDate());
const mEnd = moment(end.toJSDate());
let strStart;
let strEnd;
// All day
if (start.isDate) {
if (mEnd.diff(mStart, "days", true) === 1) {
return mStart.format(allDayFormat);
} else {
strStart = mStart.format(allDayFormat);
strEnd = mEnd.clone().subtract(1, "day").format(allDayFormat);
}
} else if (mStart.isSame(mEnd, "day")) {
strStart = mStart.format(fullFormat);
strEnd = mEnd.format("LT");
if (mStart.isSame(mEnd)) {
return strStart;
}
} else {
strStart = mStart.format(fullFormat);
strEnd = mEnd.format(fullFormat);
}
return strStart + " - " + strEnd;
}
export function formatOurTimezoneOffset() {
let offset = new Date().getTimezoneOffset();
const prefix = (offset > 0) ? "-" : "+";
offset = Math.abs(offset);
const hours = Math.floor(offset / 60);
const minutes = offset % 60;
return `GMT${prefix}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
export function getCurrentTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function mapPriority(priority: number): TaskPriorityType {
if (priority > 0 && priority < 5) {
return TaskPriorityType.High;
} else if (priority === 5) {
return TaskPriorityType.Medium;
} else if (priority > 5 && priority < 10) {
return TaskPriorityType.Low;
} else {
return TaskPriorityType.Undefined;
}
}
export function* arrayToChunkIterator<T>(arr: T[], size: number) {
for (let i = 0 ; i < arr.length ; i += size) {
yield arr.slice(i, i + size);
}
}
export function isPromise(x: any): x is Promise<any> {
return x && typeof x.then === "function";
}
export function isDefined<T>(x: T | undefined): x is T {
return x !== undefined;
}
export function startTask<T = any>(func: () => Promise<T> | T, delay = 0): Promise<T> {
return new Promise((resolve, reject) => {
setTimeout(
() => {
try {
const ret = func();
if (isPromise(ret)) {
ret.then(resolve)
.catch(reject);
} else {
resolve(ret);
}
} catch (e) {
reject(e);
}
},
delay);
});
}
export function usePromiseMemo<T>(promise: Promise<T> | undefined | null, deps: React.DependencyList, initial: T | undefined = undefined): T | undefined {
const [val, setVal] = React.useState<T>((promise as any)._returnedValue ?? initial);
React.useEffect(() => {
let cancel = false;
if (promise === undefined || promise === null) {
return undefined;
}
promise.then((val) => {
(promise as any)._returnedValue = val;
if (!cancel) {
setVal(val);
}
});
return () => {
cancel = true;
};
}, [...deps, promise]);
return val;
}
export function parseDate(prop: ICAL.Property) {
const value = prop.getFirstValue();
if ((value.day !== null) && (value.day !== undefined)) {
return {
day: value.day,
month: value.month - 1,
year: value.year ?? undefined,
};
} else {
const time = prop.toJSON()[3];
if (time.length === 6 && time.startsWith("--")) {
return {
day: parseInt(time.slice(4, 6)),
month: parseInt(time.slice(2, 4)) - 1,
};
} else if (time.length === 8) {
return {
day: parseInt(time.slice(6, 8)),
month: parseInt(time.slice(4, 6)) - 1,
year: parseInt(time.slice(0, 4)),
};
}
}
return {};
}
export const PASSWORD_MIN_LENGTH = 8;
export function enforcePasswordRules(password: string): string | undefined {
if (password.length < PASSWORD_MIN_LENGTH) {
return `Passwourds should be at least ${PASSWORD_MIN_LENGTH} digits long.`;
}
return undefined;
}

@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import SvgIcon from "@material-ui/core/SvgIcon";
export default function CopyIcon(props: any) {
return (
<SvgIcon {...props}>
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</SvgIcon>
);
}

@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
height="192"
viewBox="0 0 192 192"
width="192"
version="1.1"
id="svg3688"
sodipodi:docname="logo.svg"
inkscape:version="0.92.1 r"
inkscape:export-filename="/home/tom/projects/securesync/graphics/logo.png"
inkscape:export-xdpi="256"
inkscape:export-ydpi="256"
style="fill:#000000">
<metadata
id="metadata3694">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3692">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4498">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4488" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4490" />
<feGaussianBlur
in="composite1"
stdDeviation="4"
result="blur"
id="feGaussianBlur4492" />
<feOffset
dx="0"
dy="4"
result="offset"
id="feOffset4494" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4496" />
</filter>
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4510">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4500" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4502" />
<feGaussianBlur
in="composite1"
stdDeviation="4"
result="blur"
id="feGaussianBlur4504" />
<feOffset
dx="0"
dy="4"
result="offset"
id="feOffset4506" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4508" />
</filter>
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3832"
inkscape:window-height="2095"
id="namedview3690"
showgrid="false"
inkscape:zoom="4"
inkscape:cx="21.145622"
inkscape:cy="121.84403"
inkscape:window-x="0"
inkscape:window-y="61"
inkscape:window-maximized="0"
inkscape:current-layer="svg3688" />
<path
d="M 0,-2 H 192 V 190 H 0 Z"
id="path3684"
inkscape:connector-curvature="0"
style="fill:none;stroke-width:8" />
<path
d="M 160,67.52 V 30 H 122.48 L 96,3.52 69.52,30 H 32 V 67.52 L 5.52,94 32,120.48 V 158 H 69.52 L 96,184.48 122.48,158 H 160 V 120.48 L 186.48,94 Z"
id="path3686"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccccc"
style="fill:#ffc107;fill-opacity:1;stroke-width:8;filter:url(#filter4498)" />
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="circle"
style="display:inline"
transform="translate(0,168)">
<ellipse
style="fill:#ffd740;fill-opacity:1;fill-rule:nonzero;stroke:#ffd740;stroke-width:60.96912003;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path5101"
cx="96.040611"
cy="-74.043999"
rx="16.102007"
ry="16.103439" />
</g>
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="arrow"
style="display:inline"
transform="translate(0,168)">
<path
inkscape:connector-curvature="0"
d="M 96,-120.53035 V -137.9802 L 72.705129,-114.71374 96,-91.447272 v -17.449848 c 19.2765,0 34.94231,15.646698 34.94231,34.899704 0,5.874792 -1.45593,11.458728 -4.0766,16.28652 l 8.50263,8.492256 c 4.5425,-7.154432 7.2214,-15.646688 7.2214,-24.778776 0,-25.709454 -20.8489,-46.532934 -46.58974,-46.532934 z m 0,81.43263 c -19.276507,0 -34.942311,-15.646696 -34.942311,-34.899696 0,-5.874784 1.455934,-11.458744 4.076602,-16.286532 l -8.502618,-8.492262 c -4.542509,7.15444 -7.221415,15.646698 -7.221415,24.778794 0,25.709448 20.848909,46.532928 46.589742,46.532928 v 17.449856 L 119.29487,-33.281104 96,-56.547568 Z"
id="path2"
style="fill:#448aff;fill-opacity:1;stroke-width:5.82016563;filter:url(#filter4510)" />
<path
inkscape:connector-curvature="0"
style="fill:none;stroke-width:8"
d="M 0,-170 H 192 V 22 H 0 Z"
id="path4" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@ -0,0 +1,211 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 220 58"
xml:space="preserve"
sodipodi:docname="badge.svg"
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
inkscape:export-filename="/tmp/badge.png"
inkscape:export-xdpi="99.309998"
inkscape:export-ydpi="99.309998"
width="220"
height="58"><metadata
id="metadata1502"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs1500" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3832"
inkscape:window-height="2088"
id="namedview1498"
showgrid="false"
inkscape:zoom="2.0344828"
inkscape:cx="16.03199"
inkscape:cy="64.841524"
inkscape:window-x="0"
inkscape:window-y="68"
inkscape:window-maximized="0"
inkscape:current-layer="Capa_1" /><path
style="fill:#2aa924;fill-opacity:1"
d="M 29,0 C 29,0 22.333,8 7,8 V 27.085 C 7,37.051 11.328,46.662 19.164,52.82 21.937,55 25.208,56.875 29,58 32.792,56.875 36.062,55 38.836,52.82 46.672,46.662 51,37.051 51,27.085 V 8 C 35.667,8 29,0 29,0 Z"
id="path1463"
inkscape:connector-curvature="0" /><path
style="fill:#26e61c;fill-opacity:1"
d="M 29,51.661 C 26.877,50.828 24.822,49.636 22.872,48.103 16.69,43.245 13,35.388 13,27.085 V 13.628 C 20.391,12.685 25.639,10.114 29,7.83 Z"
id="path1465"
inkscape:connector-curvature="0" /><g
id="g1467" /><g
id="g1469" /><g
id="g1471" /><g
id="g1473" /><g
id="g1475" /><g
id="g1477" /><g
id="g1479" /><g
id="g1481" /><g
id="g1483" /><g
id="g1485" /><g
id="g1487" /><g
id="g1489" /><g
id="g1491" /><g
id="g1493" /><g
id="g1495" /><g
transform="matrix(0.06459484,0,0,0.06271697,15.219767,15.620369)"
id="g2152"><g
id="g2150"><g
id="g2148"><path
inkscape:connector-curvature="0"
d="m 133.529,352.213 c -12.907,-23.147 -19.733,-51.52 -19.733,-82.24 0,-51.627 44.693,-93.653 99.52,-93.653 54.933,0 99.52,42.027 99.52,93.653 0,5.867 4.8,10.667 10.667,10.667 5.867,0 10.667,-4.8 10.667,-10.667 0,-63.467 -54.187,-114.987 -120.853,-114.987 -66.666,0 -120.855,51.627 -120.855,114.987 0,34.347 7.787,66.453 22.507,92.693 14.4,25.707 24.427,37.547 42.88,56.213 2.133,2.133 4.8,3.2 7.573,3.2 2.667,0 5.44,-0.96 7.36,-3.2 4.267,-4.053 4.267,-10.88 0.107,-15.04 -16.427,-16.532 -25.6,-27.092 -39.36,-51.626 z"
id="path2138" /><path
inkscape:connector-curvature="0"
d="m 94.702,51.413 c 36.587,-19.947 76.48,-30.08 118.827,-30.08 42.453,0 77.973,9.067 118.827,30.187 1.6,0.747 3.2,1.173 4.907,1.173 v 0 c 3.84,0 7.573,-2.133 9.493,-5.76 2.667,-5.227 0.64,-11.733 -4.587,-14.4 C 298.649,10.027 258.969,0 213.529,0 167.662,0 124.249,10.987 84.462,32.64 c -5.227,2.88 -7.04,9.28 -4.267,14.507 2.88,5.226 9.28,7.04 14.507,4.266 z"
id="path2140" /><path
inkscape:connector-curvature="0"
d="m 212.569,103.04 c -68.8,0 -131.733,38.507 -160.213,98.027 -9.707,20.16 -14.613,43.413 -14.613,69.013 0,28.8 5.12,56.32 15.573,84.373 2.133,5.547 8.213,8.32 13.76,6.293 5.547,-2.133 8.32,-8.213 6.293,-13.76 C 60.675,312.96 59.182,286.72 59.182,270.08 c 0,-22.4 4.16,-42.56 12.48,-59.84 24.96,-52.267 80.32,-85.973 141.013,-85.973 85.227,0 154.56,65.387 154.56,145.813 0,22.933 -19.947,41.493 -44.373,41.493 -24.426,0 -44.373,-18.667 -44.373,-41.493 0,-34.667 -29.44,-62.827 -65.707,-62.827 -36.267,0 -65.707,28.16 -65.707,62.827 0,42.133 16.427,81.707 46.187,111.36 23.04,22.933 45.227,35.52 79.253,44.8 0.853,0.32 1.813,0.427 2.773,0.427 4.693,0 8.96,-3.093 10.24,-7.787 1.6,-5.653 -1.813,-11.52 -7.467,-13.12 -30.08,-8.213 -49.707,-19.307 -69.867,-39.36 -25.707,-25.6 -39.893,-59.84 -39.893,-96.213 0,-22.933 19.947,-41.493 44.373,-41.493 24.426,0 44.373,18.667 44.373,41.493 0,34.667 29.547,62.827 65.707,62.827 36.16,0 65.707,-28.16 65.707,-62.827 10e-4,-92.16 -78.932,-167.147 -175.892,-167.147 z"
id="path2142" /><path
inkscape:connector-curvature="0"
d="m 403.395,147.2 c -21.227,-29.653 -48.107,-52.907 -80,-69.333 -67.2,-34.56 -152.96,-34.453 -220.053,0.213 -32,16.533 -58.987,40 -80.107,69.867 -3.413,4.8 -2.24,11.413 2.56,14.827 1.92,1.28 4.053,1.92 6.187,1.92 3.307,0 6.613,-1.493 8.747,-4.373 19.093,-27.093 43.52,-48.32 72.427,-63.253 61.12,-31.573 139.307,-31.68 200.533,-0.213 28.8,14.72 53.12,35.84 72.32,62.72 3.413,4.8 10.133,5.867 14.933,2.453 4.8,-3.415 5.867,-10.028 2.453,-14.828 z"
id="path2144" /><path
inkscape:connector-curvature="0"
d="m 340.569,359.253 c -8.533,1.493 -17.173,2.027 -22.293,2.027 -21.333,0 -39.04,-5.013 -54.08,-15.253 -25.92,-17.6 -41.387,-45.973 -41.387,-75.947 0,-5.867 -4.8,-10.667 -10.667,-10.667 -5.867,0 -10.667,4.8 -10.667,10.667 0,37.12 18.987,72.107 50.667,93.653 18.453,12.48 40.747,18.88 66.133,18.88 2.987,0 13.547,-0.107 26.027,-2.347 5.76,-1.067 9.707,-6.613 8.64,-12.373 -1.067,-5.76 -6.613,-9.706 -12.373,-8.64 z"
id="path2146" /></g></g></g><flowRoot
xml:space="preserve"
id="flowRoot836"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion838"><rect
id="rect840"
width="181.6006"
height="31.801828"
x="60.475609"
y="0.47866088" /></flowRegion><flowPara
id="flowPara842" /></flowRoot><text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="182.81706"
y="19.942076"
id="text846"><tspan
sodipodi:role="line"
id="tspan844"
x="182.81706"
y="32.766785" /></text>
<flowRoot
xml:space="preserve"
id="flowRoot848"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion850"><rect
id="rect852"
width="160.92073"
height="37.362804"
x="61.69207"
y="1.1737828" /></flowRegion><flowPara
id="flowPara854" /></flowRoot><text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="60.067513"
y="46.389381"
id="text882"><tspan
sodipodi:role="line"
x="60.067513"
y="46.389381"
id="tspan880"><tspan
x="60.067513"
y="46.389381"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:24px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;writing-mode:lr-tb;text-anchor:start"
id="tspan878">Signed Pages</tspan></tspan></text>
<flowRoot
xml:space="preserve"
id="flowRoot864"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion866"><rect
id="rect868"
width="128.94511"
height="13.902438"
x="60.301826"
y="6.2134166" /></flowRegion><flowPara
id="flowPara870">Page s</flowPara></flowRoot><flowRoot
xml:space="preserve"
id="flowRoot872"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion874"><rect
id="rect876"
width="125.81707"
height="12.685975"
x="60.649387"
y="7.4298801" /></flowRegion><flowPara
id="flowPara878">Page</flowPara></flowRoot><flowRoot
xml:space="preserve"
id="flowRoot880"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:1.25px;line-height:25px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans';letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion882"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Open Sans';-inkscape-font-specification:'Open Sans'"><rect
id="rect884"
width="124.07926"
height="13.033536"
x="60.475609"
y="7.9512215"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Open Sans';-inkscape-font-specification:'Open Sans'" /></flowRegion><flowPara
id="flowPara886">uoeuoeuoeuaeueoaueaouaeouoaeuaoeu</flowPara></flowRoot><flowRoot
xml:space="preserve"
id="flowRoot856-9"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
transform="translate(-2.5362099,0.85067833)"><flowRegion
id="flowRegion858-3"><rect
id="rect860-7"
width="190.11584"
height="35.277439"
x="60.82317"
y="-0.3902415" /></flowRegion><flowPara
id="flowPara862-4"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:24px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start" /></flowRoot><flowRoot
xml:space="preserve"
id="flowRoot912"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion914"><rect
id="rect916"
width="88.106705"
height="77.158531"
x="-73.335365"
y="-110.39329" /></flowRegion><flowPara
id="flowPara918" /></flowRoot><flowRoot
xml:space="preserve"
id="flowRoot920"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><flowRegion
id="flowRegion922"><rect
id="rect924"
width="148.40852"
height="10.253048"
x="61.865852"
y="42.8811" /></flowRegion><flowPara
id="flowPara926">Ver</flowPara></flowRoot><text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:1.25px;line-height:25px;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="60.449413"
y="19.314518"
id="text888"><tspan
sodipodi:role="line"
x="60.449413"
y="19.314518"
id="tspan886"><tspan
x="60.449413"
y="19.314518"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:13.33333302px;font-family:'Open Sans';-inkscape-font-specification:'Open Sans, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start"
id="tspan884">PGP Signed With</tspan></tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

@ -0,0 +1,36 @@
html,
body {
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background-color: #ffc107;
/* Chrome only at the moment, disable pull to refresh in PWA */
overscroll-behavior-y: contain;
}
body:before {
content: '';
position: absolute;
height: 100%;
width: 100%;
z-index: -1;
background-color: white;
}
#root {
background-color: white;
min-height: 100%;
background-color: #f0f0f0;
display: flex;
flex-direction: column;
}
a {
color: #00b0ff;
}

@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/es/integration/react";
import App from "./App";
import registerServiceWorker from "./registerServiceWorker";
import "./index.css";
import * as Etebase from "etebase";
function MyPersistGate(props: React.PropsWithChildren<{}>) {
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
Etebase.ready.then(() => {
setLoading(false);
persistor.persist();
});
}, []);
if (loading) {
return (<React.Fragment />);
}
return (
<PersistGate persistor={persistor}>
{props.children}
</PersistGate>
);
}
import { store, persistor } from "./store";
ReactDOM.render(
<Provider store={store}>
<MyPersistGate>
<App />
</MyPersistGate>
</Provider>,
document.getElementById("root") as HTMLElement
);
registerServiceWorker();

@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { withRouter } from "react-router";
// FIXME: Should probably tie this to the history object, or at least based on the depth of the history
const stateCache = {};
type Constructor<T> = new(...args: any[]) => T;
export function historyPersistor(tag: string) {
return <T extends Constructor<React.Component>>(Base: T) => {
return withRouter(class extends Base {
constructor(...rest: any[]) {
const props = rest[0];
super(...rest);
const tagName = this.getKeyForTag(props, tag);
if (tagName in stateCache) {
this.state = stateCache[tagName];
}
}
public componentWillUnmount() {
if (super.componentWillUnmount) {
super.componentWillUnmount();
}
stateCache[this.getKeyForTag(this.props, tag)] = this.state;
}
public getKeyForTag(props: any, tagName: string) {
return props.location.pathname + ":" + tagName;
}
});
};
}

@ -0,0 +1,412 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as ICAL from "ical.js";
import * as zones from "./data/zones.json";
import moment from "moment";
import * as uuid from "uuid";
export const PRODID = "-//iCal.js EteSync iOS";
export interface PimType {
uid: string;
collectionUid?: string;
itemUid?: string;
toIcal(): string;
clone(): PimType;
lastModified: ICAL.Time | undefined;
}
export function timezoneLoadFromName(timezone: string | null) {
if (!timezone) {
return null;
}
let zone = zones.zones[timezone];
if (!zone && zones.aliases[timezone]) {
zone = zones.zones[zones.aliases[timezone]];
}
if (!zone) {
return null;
}
if (ICAL.TimezoneService.has(timezone)) {
return ICAL.TimezoneService.get(timezone);
}
const component = new ICAL.Component("vtimezone");
zone.ics.forEach((zonePart: string) => {
component.addSubcomponent(new ICAL.Component(ICAL.parse(zonePart)));
});
component.addPropertyWithValue("tzid", timezone);
const retZone = new ICAL.Timezone({
component,
tzid: timezone,
});
ICAL.TimezoneService.register(timezone, retZone);
return retZone;
}
export function parseString(content: string) {
content = content.replace(/^[a-zA-Z0-9]*\./gm, ""); // FIXME: ugly hack to ignore item groups.
return new ICAL.Component(ICAL.parse(content));
}
export class EventType extends ICAL.Event implements PimType {
public collectionUid?: string;
public itemUid?: string;
public static isEvent(comp: ICAL.Component) {
return !!comp.getFirstSubcomponent("vevent");
}
public static fromVCalendar(comp: ICAL.Component) {
const event = new EventType(comp.getFirstSubcomponent("vevent"));
// FIXME: we need to clone it so it loads the correct timezone and applies it
timezoneLoadFromName(event.timezone);
return event.clone();
}
public static parse(content: string) {
return EventType.fromVCalendar(parseString(content));
}
public color: string;
get timezone() {
if (this.startDate) {
return this.startDate.timezone;
} else if (this.endDate) {
return this.endDate.timezone;
}
return null;
}
get title() {
return this.summary;
}
set title(title: string) {
this.summary = title;
}
get start() {
return this.startDate.toJSDate();
}
get end() {
return this.endDate.toJSDate();
}
get allDay() {
return this.startDate.isDate;
}
get desc() {
return this.description;
}
get lastModified() {
return this.component.getFirstPropertyValue("last-modified");
}
set lastModified(time: ICAL.Time) {
this.component.updatePropertyWithValue("last-modified", time);
}
get rrule() {
return this.component.getFirstPropertyValue("rrule");
}
set rrule(rule: ICAL.Recur) {
this.component.updatePropertyWithValue("rrule", rule);
}
public toIcal() {
const comp = new ICAL.Component(["vcalendar", [], []]);
comp.updatePropertyWithValue("prodid", PRODID);
comp.updatePropertyWithValue("version", "2.0");
comp.addSubcomponent(this.component);
ICAL.helpers.updateTimezones(comp);
return comp.toString();
}
public clone() {
const ret = new EventType(ICAL.Component.fromString(this.component.toString()));
ret.color = this.color;
ret.collectionUid = this.collectionUid;
ret.itemUid = this.itemUid;
return ret;
}
}
export enum TaskStatusType {
NeedsAction = "NEEDS-ACTION",
Completed = "COMPLETED",
InProcess = "IN-PROCESS",
Cancelled = "CANCELLED",
}
export enum TaskPriorityType {
Undefined = 0,
High = 1,
Medium = 5,
Low = 9
}
export let TaskTags = ["Work", "Home"];
export function setTaskTags(tags: string[]) {
TaskTags = tags;
}
export class TaskType extends EventType {
public collectionUid?: string;
public itemUid?: string;
public static fromVCalendar(comp: ICAL.Component) {
const task = new TaskType(comp.getFirstSubcomponent("vtodo"));
// FIXME: we need to clone it so it loads the correct timezone and applies it
timezoneLoadFromName(task.timezone);
return task.clone();
}
public static parse(content: string) {
return TaskType.fromVCalendar(parseString(content));
}
public color: string;
constructor(comp?: ICAL.Component | null) {
super(comp ? comp : new ICAL.Component("vtodo"));
}
get finished() {
return this.status === TaskStatusType.Completed ||
this.status === TaskStatusType.Cancelled;
}
set status(status: TaskStatusType) {
this.component.updatePropertyWithValue("status", status);
}
get status(): TaskStatusType {
return this.component.getFirstPropertyValue("status");
}
set priority(priority: TaskPriorityType) {
this.component.updatePropertyWithValue("priority", priority);
}
get priority() {
return this.component.getFirstPropertyValue("priority");
}
set tags(tags: string[]) {
const property = this.component.getFirstProperty("categories");
const empty = tags.length === 0;
if (property) {
if (empty) {
this.component.removeAllProperties("categories");
} else {
property.setValues(tags);
}
} else if (!empty) {
const newProp = new ICAL.Property("categories", this.component);
newProp.setValues(tags);
this.component.addProperty(newProp);
}
}
get tags() {
return this.component.getFirstProperty("categories")?.getValues()?.filter((x) => x !== "") ?? [];
}
set dueDate(date: ICAL.Time | undefined) {
if (date) {
this.component.updatePropertyWithValue("due", date);
} else {
this.component.removeAllProperties("due");
}
}
get dueDate() {
return this.component.getFirstPropertyValue("due");
}
set completionDate(date: ICAL.Time | undefined) {
if (date) {
this.component.updatePropertyWithValue("completed", date);
} else {
this.component.removeAllProperties("completed");
}
}
get completionDate() {
return this.component.getFirstPropertyValue("completed");
}
set relatedTo(parentUid: string | undefined) {
if (parentUid !== undefined) {
this.component.updatePropertyWithValue("related-to", parentUid);
} else {
this.component.removeAllProperties("related-to");
}
}
get relatedTo(): string | undefined {
return this.component.getFirstPropertyValue("related-to");
}
get endDate() {
// XXX: A hack to override this as it shouldn't be used
return undefined as any;
}
get allDay() {
return !!((this.startDate?.isDate) || (this.dueDate?.isDate));
}
get dueToday() {
return this.dueDate && moment(this.dueDate.toJSDate()).isSameOrBefore(moment(), "day");
}
get overdue() {
if (!this.dueDate) {
return false;
}
const dueDate = moment(this.dueDate.toJSDate());
const now = moment();
return (this.dueDate.isDate) ? dueDate.isBefore(now, "day") : dueDate.isBefore(now);
}
get hidden() {
if (!this.startDate) {
return false;
}
const startDate = moment(this.startDate.toJSDate());
const now = moment();
return startDate.isAfter(now);
}
public clone() {
const ret = new TaskType(ICAL.Component.fromString(this.component.toString()));
ret.color = this.color;
return ret;
}
public getNextOccurence(): TaskType | null {
if (!this.isRecurring()) {
return null;
}
const rrule = this.rrule.clone();
if (rrule.count && rrule.count <= 1) {
return null; // end of reccurence
}
rrule.count = null; // clear count so we can iterate as many times as needed
const recur = rrule.iterator(this.startDate ?? this.dueDate);
let nextRecurrence = recur.next();
while ((nextRecurrence = recur.next())) {
if (nextRecurrence.compare(ICAL.Time.now()) > 0) {
break;
}
}
if (!nextRecurrence) {
return null; // end of reccurence
}
const nextStartDate = this.startDate ? nextRecurrence : undefined;
const nextDueDate = this.dueDate ? nextRecurrence : undefined;
if (nextStartDate && nextDueDate) {
const offset = this.dueDate!.subtractDateTz(this.startDate);
nextDueDate.addDuration(offset);
}
const nextTask = this.clone();
nextTask.uid = uuid.v4();
if (nextStartDate) {
nextTask.startDate = nextStartDate;
}
if (nextDueDate) {
nextTask.dueDate = nextDueDate;
}
if (this.rrule.count) {
rrule.count = this.rrule.count - 1;
nextTask.rrule = rrule;
}
nextTask.status = TaskStatusType.NeedsAction;
nextTask.lastModified = ICAL.Time.now();
return nextTask;
}
}
export class ContactType implements PimType {
public comp: ICAL.Component;
public collectionUid?: string;
public itemUid?: string;
public static parse(content: string) {
return new ContactType(parseString(content));
}
constructor(comp: ICAL.Component) {
this.comp = comp;
}
public toIcal() {
return this.comp.toString();
}
public clone() {
return new ContactType(ICAL.Component.fromString(this.comp.toString()));
}
get uid() {
return this.comp.getFirstPropertyValue("uid");
}
set uid(uid: string) {
this.comp.updatePropertyWithValue("uid", uid);
}
get fn() {
return this.comp.getFirstPropertyValue("fn");
}
get n() {
return this.comp.getFirstPropertyValue("n");
}
get bday() {
return this.comp.getFirstPropertyValue("bday");
}
get lastModified() {
return this.comp.getFirstPropertyValue("rev");
}
get group() {
const kind = this.comp.getFirstPropertyValue("kind");
return ["group", "organization"].includes(kind);
}
get members() {
return this.comp.getAllProperties("member").map((prop) => prop.getFirstValue<string>().replace("urn:uuid:", ""));
}
}

@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
/// <reference types="react-scripts" />

@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
// tslint:disable:no-console
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the 'N+1' visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === "[::1]" ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
process.env.PUBLIC_URL!,
window.location.toString()
);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (!isLocalhost) {
// Is not local host. Just register service worker
registerValidSW(swUrl);
} else {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
}
});
}
}
function registerValidSW(swUrl: string) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker) {
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a 'New content is
// available; please refresh.' message in your web app.
console.log("New content is available; please refresh.");
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// 'Content is cached for offline use.' message.
console.log("Content is cached for offline use.");
}
}
};
}
};
})
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(swUrl: string) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get("content-type")!.indexOf("javascript") === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
"No internet connection found. App is running in offline mode."
);
});
}
export function unregister() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister();
});
}
}

@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import { RouteResolver } from "./routes";
const routes = {
home: "",
post: {
_base: "post",
_id: {
_base: ":postId",
comment: "comment/:commentId",
revision: "history/:revisionId/:someOtherVar/test",
},
},
};
const routeResolver = new RouteResolver(routes);
it("translating routes", () => {
// Working basic resolves
expect(routeResolver.getRoute("home")).toBe("/");
expect(routeResolver.getRoute("post")).toBe("/post");
expect(routeResolver.getRoute("post._id")).toBe("/post/:postId");
expect(routeResolver.getRoute("post._id.comment")).toBe("/post/:postId/comment/:commentId");
// Working translation resolves
expect(routeResolver.getRoute("home")).toBe("/");
expect(routeResolver.getRoute("post")).toBe("/post");
expect(routeResolver.getRoute("post._id", { postId: 3 })).toBe("/post/3");
expect(routeResolver.getRoute("post._id.comment",
{ postId: 3, commentId: 5 })).toBe("/post/3/comment/5");
expect(routeResolver.getRoute("post._id.revision",
{ postId: 3, revisionId: 5, someOtherVar: "a" })).toBe("/post/3/history/5/a/test");
// Failing basic resolves
expect(() => {
routeResolver.getRoute("bad");
}).toThrow();
expect(() => {
routeResolver.getRoute("home.bad");
}).toThrow();
expect(() => {
routeResolver.getRoute("post._id.bad");
}).toThrow();
// Failing translations
expect(() => {
routeResolver.getRoute("home", { test: 4 });
}).toThrow();
expect(() => {
routeResolver.getRoute("post._id", { test: 4 });
}).toThrow();
expect(() => {
routeResolver.getRoute("post._id", { postId: 3, test: 4 });
}).toThrow();
expect(() => {
routeResolver.getRoute("post._id.comment", { postId: 3, commentId: 5, test: 4 });
}).toThrow();
expect(() => {
routeResolver.getRoute("post._id.comment", { postId: 3 });
}).toThrow();
});

@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
export interface RouteKeysType {
[Identifier: string]: any;
}
export class RouteResolver {
public routes: {};
constructor(routes: {}) {
this.routes = routes;
}
public getRoute(name: string, _keys?: RouteKeysType): string {
let dict = this.routes;
let path: string[] = [];
name.split(".").forEach((key) => {
const val = (typeof dict[key] === "string") ? dict[key] : (dict[key]._base) ? dict[key]._base : key;
path.push(val);
dict = dict[key];
});
if (_keys) {
const keys = Object.assign({}, _keys);
path = path.map((pathComponent) => {
return pathComponent.split("/").map((val) => {
if (val[0] === ":") {
const ret = keys[val.slice(1)];
if (ret === undefined) {
throw new Error("Missing key: " + val.slice(1));
}
delete keys[val.slice(1)];
return ret;
}
return val;
}).join("/");
});
if (Object.keys(keys).length !== 0) {
throw new Error("Too many keys for route.");
}
}
return "/" + path.join("/");
}
}

@ -0,0 +1,190 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import { createAction as origCreateAction, ActionMeta } from "redux-actions";
import * as Etebase from "etebase";
import { SettingsType } from "./";
import { Message } from "./reducers";
type FunctionAny = (...args: any[]) => any;
function createAction<Func extends FunctionAny, MetaFunc extends FunctionAny>(
actionType: string,
payloadCreator: Func,
metaCreator?: MetaFunc
): (..._params: Parameters<Func>) => ActionMeta<ReturnType<Func>, ReturnType<MetaFunc>> {
return origCreateAction(actionType, payloadCreator, metaCreator as any) as any;
}
export const resetKey = createAction(
"RESET_KEY",
() => {
return null;
}
);
export const logout = createAction(
"LOGOUT",
async (etebase: Etebase.Account) => {
// We don't wait on purpose, because we would like to logout and clear local data anyway
etebase.logout();
}
);
export const login = createAction(
"LOGIN",
async (etebase: Etebase.Account) => {
return etebase.save();
}
);
export const setCacheCollection = createAction(
"SET_CACHE_COLLECTION",
async (colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
return colMgr.cacheSave(col);
},
(_colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
return {
colUid: col.uid,
deleted: col.isDeleted,
};
}
);
export const unsetCacheCollection = createAction(
"UNSET_CACHE_COLLECTION",
(_colMgr: Etebase.CollectionManager, colUid: string) => {
return colUid;
},
(_colMgr: Etebase.CollectionManager, colUid: string) => {
return {
colUid,
deleted: true,
};
}
);
export const collectionUpload = createAction(
"COLLECTION_UPLOAD",
async (colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
await colMgr.upload(col);
return colMgr.cacheSave(col);
},
(_colMgr: Etebase.CollectionManager, col: Etebase.Collection) => {
return {
colUid: col.uid,
deleted: col.isDeleted,
};
}
);
export const setCacheItem = createAction(
"SET_CACHE_ITEM",
async (_col: Etebase.Collection, itemMgr: Etebase.ItemManager, item: Etebase.Item) => {
return itemMgr.cacheSave(item);
},
(col: Etebase.Collection, _itemMgr: Etebase.ItemManager, item: Etebase.Item) => {
return {
colUid: col.uid,
itemUid: item.uid,
deleted: item.isDeleted,
};
}
);
export const setCacheItemMulti = createAction(
"SET_CACHE_ITEM_MULTI",
async (_colUid: string, itemMgr: Etebase.ItemManager, items: Etebase.Item[]) => {
const ret = [];
for (const item of items) {
ret.push(itemMgr.cacheSave(item));
}
return ret;
},
(colUid: string, _itemMgr: Etebase.ItemManager, items: Etebase.Item[], _deps?: Etebase.Item[]) => {
return {
colUid,
items: items,
};
}
);
export const itemBatch = createAction(
"ITEM_BATCH",
async (_col: Etebase.Collection, itemMgr: Etebase.ItemManager, items: Etebase.Item[], deps?: Etebase.Item[]) => {
await itemMgr.batch(items, deps);
const ret = [];
for (const item of items) {
ret.push(itemMgr.cacheSave(item));
}
return ret;
},
(col: Etebase.Collection, _itemMgr: Etebase.ItemManager, items: Etebase.Item[], _deps?: Etebase.Item[]) => {
return {
colUid: col.uid,
items: items,
};
}
);
export const setSyncCollection = createAction(
"SET_SYNC_COLLECTION",
(uid: string, stoken: string) => {
return {
uid,
stoken,
};
}
);
export const setSyncGeneral = createAction(
"SET_SYNC_GENERAL",
(stoken: string | null) => {
return stoken;
}
);
export const performSync = createAction(
"PERFORM_SYNC",
(syncPromise: Promise<any>) => {
return syncPromise;
}
);
export const appendError = createAction(
"APPEND_ERROR",
(error: Error | Error[]) => {
return error;
}
);
export const clearErros = createAction(
"CLEAR_ERRORS",
(_etesync: Etebase.Account) => {
return true;
}
);
export const pushMessage = createAction(
"PUSH_MESSAGE",
(message: Message) => {
return message;
}
);
export const popMessage = createAction(
"POP_MESSAGE",
() => {
return true;
}
);
// FIXME: Move the rest to their own file
export const setSettings = createAction(
"SET_SETTINGS",
(settings: Partial<SettingsType>) => {
return { ...settings };
}
);

@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as localforage from "localforage";
import { combineReducers } from "redux";
import { createMigrate, persistReducer, createTransform } from "redux-persist";
import session from "redux-persist/lib/storage/session";
import * as Etebase from "etebase";
import { List, Map as ImmutableMap } from "immutable";
import {
SettingsType,
fetchCount, credentials, settingsReducer, encryptionKeyReducer, errorsReducer, messagesReducer,
syncGeneral, syncCollections, collections, items,
SyncGeneralData, SyncCollectionsData, CacheCollectionsData, CacheItemsData, CredentialsData, Message,
} from "./reducers";
export interface StoreState {
fetchCount: number;
credentials: CredentialsData;
settings: SettingsType;
encryptionKey: {key: string};
sync: {
collections: SyncCollectionsData;
general: SyncGeneralData;
};
cache: {
collections: CacheCollectionsData;
items: CacheItemsData;
};
errors: List<Error>;
messages: List<Message>;
}
const settingsMigrations = {
0: (state: any) => {
return {
...state,
taskSettings: {
filterBy: null,
sortBy: "smart",
},
};
},
};
const settingsPersistConfig = {
key: "settings",
version: 0,
storage: localforage,
migrate: createMigrate(settingsMigrations, { debug: false }),
};
const credentialsPersistConfig = {
key: "credentials2",
version: 0,
storage: localforage,
};
const encryptionKeyPersistConfig = {
key: "encryptionKey",
storage: session,
};
const syncSerialize = (state: any, key: string | number) => {
if (key === "collections") {
return state.toJS();
}
return state;
};
const syncDeserialize = (state: any, key: string | number) => {
if (key === "collections") {
return ImmutableMap(state);
}
return state;
};
const syncPersistConfig = {
key: "sync",
storage: localforage,
transforms: [createTransform(syncSerialize, syncDeserialize)],
};
const cacheSerialize = (state: any, key: string | number) => {
if (key === "collections") {
const typedState = state as CacheCollectionsData;
const ret = typedState.map((x) => Etebase.toBase64(x));
return ret.toJS();
} else if (key === "items") {
const typedState = state as CacheItemsData;
const ret = typedState.map((items) => {
return items.map((x) => Etebase.toBase64(x));
});
return ret.toJS();
}
return state;
};
const cacheDeserialize = (state: any, key: string | number) => {
if (key === "collections") {
return ImmutableMap<string, string>(state).map((x) => {
return Etebase.fromBase64(x);
});
} else if (key === "items") {
return ImmutableMap(state).map((item: any) => {
return ImmutableMap<string, string>(item).map((x) => Etebase.fromBase64(x));
});
}
return state;
};
const cachePersistConfig = {
key: "cache2",
version: 0,
storage: localforage,
transforms: [createTransform(cacheSerialize, cacheDeserialize)] as any,
};
const reducers = combineReducers({
fetchCount,
settings: persistReducer(settingsPersistConfig, settingsReducer),
credentials: persistReducer(credentialsPersistConfig, credentials),
encryptionKey: persistReducer(encryptionKeyPersistConfig, encryptionKeyReducer),
sync: persistReducer(syncPersistConfig, combineReducers({
collections: syncCollections,
general: syncGeneral,
})),
cache: persistReducer(cachePersistConfig, combineReducers({
collections,
items,
})),
errors: errorsReducer,
messages: messagesReducer,
});
export default reducers;

@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import { createStore, applyMiddleware } from "redux";
import { persistStore } from "redux-persist";
import thunkMiddleware from "redux-thunk";
import { createLogger } from "redux-logger";
import { ActionMeta } from "redux-actions";
import { useDispatch } from "react-redux";
import promiseMiddleware from "./promise-middleware";
import reducers from "./construct";
// Workaround babel limitation
export * from "./reducers";
export * from "./construct";
const middleware = [
thunkMiddleware,
promiseMiddleware,
];
if (process.env.NODE_ENV === "development") {
middleware.push(createLogger());
}
// FIXME: Hack, we don't actually return a promise when one is not passed.
export function asyncDispatch<T, V>(action: ActionMeta<Promise<T> | T, V>): Promise<ActionMeta<T, V>> {
return store.dispatch(action) as any;
}
export function useAsyncDispatch() {
const dispatch = useDispatch();
return function (action: any): any {
return dispatch(action) as any;
} as typeof asyncDispatch;
}
export const store = createStore(
reducers,
applyMiddleware(...middleware)
);
export const persistor = persistStore(store, { manualPersist: true } as any);

@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
// Based on: https://github.com/acdlite/redux-promise/blob/master/src/index.js
function isPromise(val: any): val is Promise<any> {
return val && typeof val.then === "function";
}
export default function promiseMiddleware({ dispatch }: any) {
return (next: any) => (action: any) => {
if (isPromise(action.payload)) {
dispatch({ ...action, payload: undefined });
return action.payload
.then((result: any) => dispatch({ ...action, payload: result }))
.catch((error: Error) => {
dispatch({ ...action, payload: error, error: true });
return Promise.reject(error);
});
} else {
return next(action);
}
};
}

@ -0,0 +1,264 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import { Action, ActionMeta, ActionFunctionAny, combineActions, handleAction, handleActions } from "redux-actions";
import { List, Map as ImmutableMap } from "immutable";
import * as Etebase from "etebase";
import * as actions from "./actions";
interface BaseModel {
uid: string;
}
export interface SyncCollectionsEntryData extends BaseModel {
stoken: string;
}
export type SyncCollectionsData = ImmutableMap<string, SyncCollectionsEntryData>;
export type CacheItem = Uint8Array;
export type CacheItems = ImmutableMap<string, CacheItem>;
export type CacheItemsData = ImmutableMap<string, CacheItems>;
export type CacheCollection = Uint8Array;
export type CacheCollectionsData = ImmutableMap<string, Uint8Array>;
export type SyncGeneralData = {
stoken: string | null;
lastSyncDate: Date;
};
export type Message = {
message: string;
severity: "error" | "warning" | "info" | "success";
};
export interface CredentialsData {
storedSession?: string;
}
export const credentials = handleActions(
{
[actions.login.toString()]: (
state: CredentialsData, action: Action<string>) => {
if (action.error) {
return state;
} else if (action.payload === undefined) {
return state;
} else {
return {
storedSession: action.payload,
};
}
},
[actions.logout.toString()]: (_state: CredentialsData, _action: any) => {
return { storedSession: undefined };
},
},
{ storedSession: undefined }
);
export const encryptionKeyReducer = handleActions(
{
},
{ key: null }
);
export const syncCollections = handleActions(
{
[actions.setSyncCollection.toString()]: (state: SyncCollectionsData, action: Action<SyncCollectionsEntryData>) => {
if (action.payload !== undefined) {
return state.set(action.payload.uid, action.payload);
}
return state;
},
[actions.logout.toString()]: (state: SyncCollectionsData, _action: any) => {
return state.clear();
},
},
ImmutableMap({})
);
export const syncGeneral = handleActions(
{
[actions.setSyncGeneral.toString()]: (state: SyncGeneralData, action: Action<string | null | undefined>) => {
if (action.payload !== undefined) {
return {
stoken: action.payload,
lastSyncDate: new Date(),
};
}
return state;
},
[actions.logout.toString()]: (_state: SyncGeneralData, _action: any) => {
return {};
},
},
{}
);
export const collections = handleActions(
{
[combineActions(
actions.setCacheCollection,
actions.collectionUpload,
actions.unsetCacheCollection
).toString()]: (state: CacheCollectionsData, action: ActionMeta<CacheCollection, { colUid: string, deleted: boolean }>) => {
if (action.payload !== undefined) {
if (action.meta.deleted) {
return state.remove(action.meta.colUid);
} else {
return state.set(action.meta.colUid, action.payload);
}
}
return state;
},
[actions.logout.toString()]: (state: CacheCollectionsData, _action: any) => {
return state.clear();
},
},
ImmutableMap({})
);
export const items = handleActions(
{
[combineActions(
actions.setCacheItem
).toString()]: (state: CacheItemsData, action: ActionMeta<CacheItem, { colUid: string, itemUid: string, deleted: boolean }>) => {
if (action.payload !== undefined) {
return state.setIn([action.meta.colUid, action.meta.itemUid], action.payload);
}
return state;
},
[combineActions(
actions.itemBatch,
actions.setCacheItemMulti
).toString()]: (state: CacheItemsData, action_: any) => {
// Fails without it for some reason
const action = action_ as ActionMeta<CacheItem[], { colUid: string, items: Etebase.Item[] }>;
if (action.payload !== undefined) {
return state.withMutations((state) => {
let i = 0;
for (const item of action.meta.items) {
state.setIn([action.meta.colUid, item.uid], action.payload[i]);
i++;
}
});
}
return state;
},
[actions.setCacheCollection.toString()]: (state: CacheItemsData, action: ActionMeta<CacheCollection, { colUid: string }>) => {
if (action.payload !== undefined) {
if (!state.has(action.meta.colUid)) {
return state.set(action.meta.colUid, ImmutableMap());
}
}
return state;
},
[actions.unsetCacheCollection.toString()]: (state: CacheItemsData, action: ActionMeta<string, { colUid: string }>) => {
if (action.payload !== undefined) {
return state.remove(action.meta.colUid);
}
return state;
},
[actions.logout.toString()]: (state: CacheItemsData, _action: any) => {
return state.clear();
},
},
ImmutableMap({})
);
const fetchActions = [
] as Array<ActionFunctionAny<Action<any>>>;
for (const func in actions) {
if (func.startsWith("fetch") ||
func.startsWith("add") ||
func.startsWith("update") ||
func.startsWith("delete")) {
fetchActions.push(actions[func]);
}
}
// Indicates network activity, not just fetch
export const fetchCount = handleAction(
combineActions(
actions.performSync.toString(),
...fetchActions
),
(state: number, action: any) => {
if (action.payload === undefined) {
return state + 1;
} else {
return state - 1;
}
},
0
);
export const errorsReducer = handleActions(
{
[combineActions(
actions.performSync
).toString()]: (state: List<Error>, action: Action<any>) => {
if (action.error) {
return state.push(action.payload);
}
return state;
},
[actions.appendError.toString()]: (state: List<Error>, action: Action<any>) => {
if (Array.isArray(action.payload)) {
return state.push(...action.payload);
} else {
return state.push(action.payload);
}
},
[actions.clearErros.toString()]: (state: List<Error>, _action: Action<any>) => {
return state.clear();
},
},
List([])
);
export const messagesReducer = handleActions(
{
[actions.pushMessage.toString()]: (state: List<Message>, action: Action<Message>) => {
return state.push(action.payload);
},
[actions.popMessage.toString()]: (state: List<Message>, _action: Action<unknown>) => {
return state.remove(0);
},
},
List([])
);
// FIXME Move all the below (potentially the fetchCount ones too) to their own file
export interface SettingsType {
locale: string;
darkMode?: boolean;
taskSettings: {
filterBy: string | null;
sortBy: string;
};
}
export const settingsReducer = handleActions(
{
[actions.setSettings.toString()]: (state: { key: string | null }, action: any) => (
{ ...state, ...action.payload }
),
},
{
locale: "en-gb",
darkMode: false,
taskSettings: {
filterBy: null,
sortBy: "smart",
},
}
);

@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: © 2019 EteSync Authors
// SPDX-License-Identifier: GPL-3.0-only
import * as Etebase from "etebase";
import { store, StoreState } from "../store";
import { credentialsSelector } from "../credentials";
import { setSyncCollection, setSyncGeneral, setCacheCollection, unsetCacheCollection, setCacheItemMulti, appendError } from "../store/actions";
const cachedSyncManager = new Map<string, SyncManager>();
export class SyncManager {
private COLLECTION_TYPES = ["etebase.vcard", "etebase.vevent", "etebase.vtodo"];
private BATCH_SIZE = 40;
public static getManager(etebase: Etebase.Account) {
const cached = cachedSyncManager.get(etebase.user.username);
if (cached) {
return cached;
}
const ret = new SyncManager();
cachedSyncManager.set(etebase.user.username, ret);
return ret;
}
public static removeManager(etebase: Etebase.Account) {
cachedSyncManager.delete(etebase.user.username);
}
protected etebase: Etebase.Account;
protected isSyncing: boolean;
private async fetchCollection(col: Etebase.Collection) {
const storeState = store.getState() as unknown as StoreState;
const etebase = (await credentialsSelector(storeState))!;
const syncCollection = storeState.sync.collections.get(col.uid, undefined);
const colMgr = etebase.getCollectionManager();
const itemMgr = colMgr.getItemManager(col);
let stoken = syncCollection?.stoken;
const limit = this.BATCH_SIZE;
let done = false;
while (!done) {
const items = await itemMgr.list({ stoken, limit });
store.dispatch(setCacheItemMulti(col.uid, itemMgr, items.data));
done = items.done;
stoken = items.stoken;
}
if (syncCollection?.stoken !== stoken) {
store.dispatch(setSyncCollection(col.uid, stoken!));
}
}
private async fetchAllCollections() {
const storeState = store.getState() as unknown as StoreState;
const etebase = (await credentialsSelector(storeState))!;
const syncGeneral = storeState.sync.general;
const colMgr = etebase.getCollectionManager();
const limit = this.BATCH_SIZE;
let stoken = syncGeneral?.stoken;
let done = false;
while (!done) {
const collections = await colMgr.list(this.COLLECTION_TYPES, { stoken, limit });
for (const col of collections.data) {
const collectionType = col.getCollectionType();
if (this.COLLECTION_TYPES.includes(collectionType)) {
store.dispatch(setCacheCollection(colMgr, col));
await this.fetchCollection(col);
}
}
if (collections.removedMemberships) {
for (const removed of collections.removedMemberships) {
store.dispatch(unsetCacheCollection(colMgr, removed.uid));
}
}
done = collections.done;
stoken = collections.stoken;
}
if (syncGeneral?.stoken !== stoken) {
store.dispatch(setSyncGeneral(stoken));
}
return true;
}
public async sync(alwaysThrowErrors = false) {
if (this.isSyncing) {
return false;
}
this.isSyncing = true;
try {
const stoken = await this.fetchAllCollections();
return stoken;
} catch (e) {
if (alwaysThrowErrors) {
throw e;
}
if (e instanceof Etebase.NetworkError || e instanceof Etebase.TemporaryServerError) {
// Ignore network errors
return null;
} else if (e instanceof Etebase.PermissionDeniedError) {
store.dispatch(appendError(e));
return null;
} else if (e instanceof Etebase.HttpError) {
store.dispatch(appendError(e));
return null;
}
throw e;
} finally {
this.isSyncing = false;
}
}
}

@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: MPL-2.0
// Disable some style eslint rules for things we can't control
/* eslint-disable @typescript-eslint/camelcase, @typescript-eslint/class-name-casing */
declare module "ical.js" {
function parse(input: string): any[];
export class helpers {
static public updateTimezones(vcal: Component): Component;
}
class Component {
static public fromString(str: string): Component;
public name: string;
constructor(jCal: any[] | string, parent?: Component);
public toJSON(): any[];
public getFirstSubcomponent(name?: string): Component | null;
public getAllSubcomponents(name?: string): Component[];
public getFirstPropertyValue<T = any>(name?: string): T;
public getFirstProperty(name?: string): Property;
public getAllProperties(name?: string): Property[];
public addProperty(property: Property): Property;
public addPropertyWithValue(name: string, value: string | number | object): Property;
public updatePropertyWithValue(name: string, value: string | number | object): Property;
public removeAllProperties(name?: string): boolean;
public addSubcomponent(component: Component): Component;
}
export class Event {
public uid: string;
public summary: string;
public startDate: Time;
public endDate: Time;
public description: string;
public location: string;
public attendees: Property[];
public component: Component;
public constructor(component?: Component | null, options?: {strictExceptions: boolean, exepctions: Array<Component | Event>});
public isRecurring(): boolean;
public iterator(startTime?: Time): RecurExpansion;
}
export class Property {
public name: string;
public type: string;
constructor(jCal: any[] | string, parent?: Component);
public getFirstValue<T = any>(): T;
public getValues<T = any>(): T[];
public setParameter(name: string, value: string | string[]): void;
public setValue(value: string | object): void;
public setValues(values: (string | object)[]): void;
public toJSON(): any;
}
interface TimeJsonData {
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
isDate?: boolean;
}
export class Time {
static public fromString(str: string): Time;
static public fromJSDate(aDate: Date | null, useUTC: boolean): Time;
static public fromData(aData: TimeJsonData): Time;
static public now(): Time;
public isDate: boolean;
public timezone: string;
public zone: Timezone;
public year: number;
public month: number;
public day: number;
public hour: number;
public minute: number;
public second: number;
constructor(data?: TimeJsonData);
public compare(aOther: Time): number;
public clone(): Time;
public convertToZone(zone: Timezone): Time;
public adjust(
aExtraDays: number, aExtraHours: number, aExtraMinutes: number, aExtraSeconds: number, aTimeopt?: Time): void;
public addDuration(aDuration: Duration): void;
public subtractDateTz(aDate: Time): Duration;
public toUnixTime(): number;
public toJSDate(): Date;
public toJSON(): TimeJsonData;
}
export class Duration {
public days: number;
}
export class RecurExpansion {
public complete: boolean;
public next(): Time;
}
export class Timezone {
static public utcTimezone: Timezone;
static public localTimezone: Timezone;
static public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time;
public tzid: string;
public component: Component;
constructor(data: Component | {
component: string | Component;
tzid?: string;
location?: string;
tznames?: string;
latitude?: number;
longitude?: number;
});
}
export class TimezoneService {
static public get(tzid: string): Timezone | null;
static public has(tzid: string): boolean;
static public register(tzid: string, zone: Timezone | Component);
static public remove(tzid: string): Timezone | null;
}
export type FrequencyValues = "YEARLY" | "MONTHLY" | "WEEKLY" | "DAILY" | "HOURLY" | "MINUTELY" | "SECONDLY";
export enum WeekDay {
SU = 1,
MO,
TU,
WE,
TH,
FR,
SA,
}
export class RecurData {
public freq?: FrequencyValues;
public interval?: number;
public wkst?: WeekDay;
public until?: Time;
public count?: number;
public bysecond?: number[] | number;
public byminute?: number[] | number;
public byhour?: number[] | number;
public byday?: string[] | string;
public bymonthday?: number[] | number;
public byyearday?: number[] | number;
public byweekno?: number[] | number;
public bymonth?: number[] | number;
public bysetpos?: number[] | number;
}
export class RecurIterator {
public next(): Time;
}
export class Recur {
constructor(data?: RecurData);
public until: Time | null;
public freq: FrequencyValues;
public count: number | null;
public clone(): Recur;
public toJSON(): Omit<RecurData, "until"> & { until?: string };
public iterator(startTime?: Time): RecurIterator;
public isByCount(): boolean;
}
}

@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
declare module "redux-persist";
declare module "redux-persist/lib/storage/session";
declare module "redux-persist/es/integration/react";

@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import * as ReactDOM from "react-dom";
export default (props: {title: string, children?: React.ReactNode | React.ReactNode[]}) => {
const titleEl = document.querySelector("#appbar-title");
const buttonsEl = document.querySelector("#appbar-buttons");
return (
<>
{titleEl && ReactDOM.createPortal(
<span>{props.title}</span>,
titleEl
)}
{buttonsEl && props.children && ReactDOM.createPortal(
props.children,
buttonsEl
)}
</>
);
};

@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
export const Avatar = React.memo((props: { children: React.ReactNode[] | React.ReactNode, size?: number, style?: any }) => {
const size = (props.size) ? props.size : 40;
return (
<div
style={{
backgroundColor: "grey",
color: "white",
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "50%",
height: size,
width: size,
...props.style,
}}
>
{props.children}
</div>
);
});

@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
interface PropsType {
color: string;
size?: number | string;
style?: React.CSSProperties;
}
export default function ColorBox(props: PropsType) {
const size = props.size ?? 64;
const style = { ...props.style, backgroundColor: props.color, width: size, height: size };
return (
<div style={style} />
);
}

@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import ColorBox from "./ColorBox";
import { TextField, ButtonBase } from "@material-ui/core";
interface PropsType {
color: string;
defaultColor: string;
label?: string;
placeholder?: string;
error?: string;
onChange: (color: string) => void;
}
export default function ColorPicker(props: PropsType) {
const colors = [
[
"#F44336",
"#E91E63",
"#673AB7",
"#3F51B5",
"#2196F3",
],
[
"#03A9F4",
"#4CAF50",
"#8BC34A",
"#FFEB3B",
"#FF9800",
],
];
const color = props.color;
return (
<div>
{colors.map((colorGroup, idx) => (
<div key={idx} style={{ flex: 1, flexDirection: "row", justifyContent: "space-between" }}>
{colorGroup.map((colorOption) => (
<ButtonBase
style={{ margin: 5, borderRadius: 36 / 2 }}
key={colorOption}
onClick={() => props.onChange(colorOption)}
>
<ColorBox size={36} style={{ borderRadius: 36 / 2 }} color={colorOption} />
</ButtonBase>
))}
</div>
))}
<div style={{ flex: 1, alignItems: "center", flexDirection: "row", margin: 5 }}>
<ColorBox
style={{ display: "inline-block" }}
size={36}
color={color}
/>
<TextField
style={{ marginLeft: 10, flex: 1 }}
error={!!props.error}
onChange={(event: React.FormEvent<{ value: string }>) => props.onChange(event.currentTarget.value)}
placeholder={props.placeholder ?? "E.g. #aabbcc"}
label={props.label ?? "Color"}
value={color}
helperText={props.error}
/>
</div>
</div>
);
}

@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { makeStyles } from "@material-ui/core/styles";
import Radio from "@material-ui/core/Radio";
import { Omit } from "@material-ui/types";
import FormControlLabel, { FormControlLabelProps } from "@material-ui/core/FormControlLabel";
interface Props {
color: string;
label: string;
}
const useStyles = makeStyles({
root: {
color: (props: Props) => props.color,
},
});
export default function ColoredRadio(props: Props & Omit<FormControlLabelProps, keyof Props | "control">) {
const { color, label, value, ...other } = props;
const { root } = useStyles(props);
return <FormControlLabel
className={root}
label={label}
control={<Radio color="default" className={root} value={value} />}
{...other}
/>;
}

@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Dialog from "@material-ui/core/Dialog";
import DialogTitle from "@material-ui/core/DialogTitle";
import DialogContent from "@material-ui/core/DialogContent";
import DialogActions from "@material-ui/core/DialogActions";
import Button from "@material-ui/core/Button";
export default React.memo((_props: any) => {
const {
title,
children,
onCancel,
onOk,
labelOk,
...props
} = _props;
return (
<Dialog
onClose={onCancel}
{...props}
>
<DialogTitle>
{title}
</DialogTitle>
<DialogContent>
{children}
</DialogContent>
<DialogActions>
<Button
color="primary"
onClick={onCancel}
>
Cancel
</Button>
<Button
color="primary"
onClick={onOk}
>
{labelOk || "Confirm"}
</Button>
</DialogActions>
</Dialog>
);
});

@ -0,0 +1,25 @@
.Container {
margin-right: auto;
margin-left: auto;
}
@media (min-width: 768px) {
.Container {
padding-top: 40px;
padding-bottom: 40px;
width: 750px;
}
}
@media (min-width: 992px) {
.Container {
width: 970px;
}
}
@media (min-width: 1200px) {
.Container {
width: 1170px;
}
}
.Container-inner {
padding: 15px;
}

@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import Paper from "@material-ui/core/Paper";
import "./Container.css";
export default (props: {style?: React.CSSProperties, children: any}) => {
const display = props.style?.display;
const flexDirection = props.style?.flexDirection;
return (
<div className="Container" style={props.style}>
<Paper elevation={3} style={{ display, flexDirection, flexGrow: 1 }}>
<div className="Container-inner" style={{ display, flexDirection, flexGrow: 1 }}>
{props.children}
</div>
</Paper>
</div>
);
};

@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import MomentUtils from "@date-io/moment";
import { MuiPickersUtilsProvider, KeyboardDatePicker, KeyboardDateTimePicker } from "@material-ui/pickers";
import moment from "moment";
interface PropsType {
placeholder: string;
value?: Date;
dateOnly?: boolean;
onChange: (date?: Date) => void;
}
class DateTimePicker extends React.PureComponent<PropsType> {
constructor(props: any) {
super(props);
this.handleInputChange = this.handleInputChange.bind(this);
}
public render() {
const Picker = (this.props.dateOnly) ? KeyboardDatePicker : KeyboardDateTimePicker;
const dateFormat = (this.props.dateOnly) ? "L" : "L LT";
return (
<MuiPickersUtilsProvider utils={MomentUtils}>
<Picker
value={this.props.value || null}
onChange={this.handleInputChange}
format={dateFormat}
ampm={false}
showTodayButton
KeyboardButtonProps={{
"aria-label": "change date",
}}
/>
</MuiPickersUtilsProvider>
);
}
private handleInputChange(date: moment.Moment) {
if (moment.isMoment(date)) {
this.props.onChange(date.toDate());
} else {
this.props.onChange(undefined);
}
}
}
export default DateTimePicker;

@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
export const ExternalLink = React.memo(({ children, ...props }: any) => (
<a target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
));
export default ExternalLink;

@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: © 2017 EteSync Authors
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from "react";
import { createStyles, makeStyles } from "@material-ui/core/styles";
import MuiList from "@material-ui/core/List";
import MuiListItem from "@material-ui/core/ListItem";
import MuiListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction";
import MuiListSubheader from "@material-ui/core/ListSubheader";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import Divider from "@material-ui/core/Divider";
import ExternalLink from "./ExternalLink";
const useStyles = makeStyles((theme) => (createStyles({
inset: {
marginLeft: 64,
},
nested: {
paddingLeft: theme.spacing(4),
},
})));
export const List = MuiList;
export const ListSubheader = MuiListSubheader;
export const ListDivider = React.memo(function ListDivider(props: { inset?: boolean }) {
const classes = useStyles();
const insetClass = (props.inset) ? classes.inset : undefined;
return (
<Divider className={insetClass} />
);
});
interface ListItemPropsType {
leftIcon?: React.ReactElement;
rightIcon?: React.ReactElement;
style?: React.CSSProperties;
primaryText?: string;
secondaryText?: string;
children?: React.ReactNode | React.ReactNode[];
onClick?: () => void;
href?: string;
insetChildren?: boolean;
nestedItems?: React.ReactNode[];
selected?: boolean;
secondaryTextColor?: "initial" | "inherit" | "primary" | "secondary" | "textPrimary" | "textSecondary" | "error";
secondaryAction?: React.ReactNode;
}
export const ListItem = React.memo(function ListItem(_props: ListItemPropsType) {
const classes = useStyles();
const {
leftIcon,
rightIcon,
primaryText,
secondaryText,
children,
onClick,
href,
style,
insetChildren,
nestedItems,
selected,
secondaryTextColor,
secondaryAction,
} = _props;
const extraProps = (onClick || href) ? {
button: true,
href,
onClick,
component: (href) ? ExternalLink : "div",
} : undefined;
return (
<>
<MuiListItem
style={style}
onClick={onClick}
selected={selected}
{...(extraProps as any)}
>
{leftIcon && (
<ListItemIcon>
{leftIcon}
</ListItemIcon>
)}
<ListItemText inset={insetChildren} primary={primaryText} secondary={secondaryText} secondaryTypographyProps={{ color: secondaryTextColor }}>
{children}
</ListItemText>
{rightIcon && (
<ListItemIcon>
{rightIcon}
</ListItemIcon>
)}
{secondaryAction && (
<MuiListItemSecondaryAction>
{secondaryAction}
</MuiListItemSecondaryAction>
)}
</MuiListItem>
{nestedItems && (
<List className={classes.nested} disablePadding>
{nestedItems}
</List>
)}
</>
);
});

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

Loading…
Cancel
Save