+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 0fc3113..6940d01 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
index f8051a6..17efb20 100644
--- a/.idea/migrations.xml
+++ b/.idea/migrations.xml
@@ -4,6 +4,7 @@
+
+
\ No newline at end of file
diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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
+.
diff --git a/README.md b/README.md
index 49c2e3e..28d49b3 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@

-# TPUvNATIVE
-The native Android client for TPU.
+# Flowinity Kotlin (formerly PrivateUploader)
+The native Android client for Flowinity.
diff --git a/app/benchmark/.gitignore b/app/benchmark/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/benchmark/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/benchmark/build.gradle.kts b/app/benchmark/build.gradle.kts
new file mode 100644
index 0000000..474144b
--- /dev/null
+++ b/app/benchmark/build.gradle.kts
@@ -0,0 +1,53 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.androidTest)
+ alias(libs.plugins.kotlinAndroid)
+}
+
+android {
+ namespace = "com.troplo.benchmark"
+ compileSdk = 33
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ defaultConfig {
+ minSdk = 26
+ targetSdk = 33
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ // This benchmark buildType is used for benchmarking, and should function like your
+ // release build (for example, with minification on). It"s signed with a debug key
+ // for easy local/CI testing.
+ create("benchmark") {
+ isDebuggable = true
+ signingConfig = getByName("debug").signingConfig
+ matchingFallbacks += listOf("release")
+ }
+ }
+
+ targetProjectPath = ":app"
+ experimentalProperties["android.experimental.self-instrumenting"] = true
+}
+
+dependencies {
+ implementation(libs.androidx.test.ext.junit)
+ implementation(libs.espresso.core)
+ implementation(libs.uiautomator)
+ implementation(libs.benchmark.macro.junit4)
+}
+
+androidComponents {
+ beforeVariants(selector().all()) {
+ it.enable = it.buildType == "benchmark"
+ }
+}
\ No newline at end of file
diff --git a/app/benchmark/src/main/AndroidManifest.xml b/app/benchmark/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..227314e
--- /dev/null
+++ b/app/benchmark/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/benchmark/src/main/java/com/troplo/benchmark/ExampleStartupBenchmark.kt b/app/benchmark/src/main/java/com/troplo/benchmark/ExampleStartupBenchmark.kt
new file mode 100644
index 0000000..5a59c62
--- /dev/null
+++ b/app/benchmark/src/main/java/com/troplo/benchmark/ExampleStartupBenchmark.kt
@@ -0,0 +1,39 @@
+package com.troplo.benchmark
+
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This is an example startup benchmark.
+ *
+ * It navigates to the device's home screen, and launches the default activity.
+ *
+ * Before running this benchmark:
+ * 1) switch your app's active build variant in the Studio (affects Studio runs only)
+ * 2) add `` to your app's manifest, within the `` tag
+ *
+ * Run this benchmark from Studio to see startup measurements, and captured system traces
+ * for investigating your app's performance.
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleStartupBenchmark {
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ @Test
+ fun startup() = benchmarkRule.measureRepeated(
+ packageName = "com.troplo.privateuploader",
+ metrics = listOf(StartupTimingMetric()),
+ iterations = 5,
+ startupMode = StartupMode.COLD
+ ) {
+ pressHome()
+ startActivityAndWait()
+ }
+}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e1b618b..1271f27 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,25 +1,42 @@
+import java.text.DateFormat
+
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.10"
id("io.sentry.android.gradle") version "3.11.1"
id("com.google.gms.google-services")
+// id("com.apollographql.apollo3") version "3.8.6"
+ id("se.patrikerdes.use-latest-versions") version "0.2.18"
+ id("com.github.ben-manes.versions") version "0.41.0"
+}
+
+//apollo {
+// service("service") {
+// packageName.set("com.troplo.privateuploader")
+// }
+//}
+
+sentry {
+ autoUploadProguardMapping
+ autoUploadNativeSymbols
+ autoUploadSourceContext
}
android {
namespace = "com.troplo.privateuploader"
- compileSdk = 34
+ compileSdk = 36
defaultConfig {
applicationId = "com.troplo.privateuploader"
minSdk = 28
- targetSdk = 34
- versionCode = 7
- versionName = "1.0.7"
+ targetSdk = 36
+ versionCode = 15
+ versionName = "1.0.15"
multiDexEnabled = true
- buildConfigField("String", "SERVER_URL", "\"https://privateuploader.com\"")
- buildConfigField("String", "BUILD_TIME", "\"${System.currentTimeMillis()}\"")
- buildConfigField("Integer", "BETA_VERSION", "6")
+ buildConfigField("String", "SERVER_URL", "\"https://flowinity.com\"")
+ buildConfigField("String", "BUILD_TIME", "\"${DateFormat.getDateTimeInstance().format(System.currentTimeMillis())}\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -29,8 +46,9 @@ android {
buildTypes {
release {
- isMinifyEnabled = false
- buildConfigField("String", "SERVER_URL", "\"https://privateuploader.com\"")
+ isMinifyEnabled = true
+ isShrinkResources = true
+ buildConfigField("String", "SERVER_URL", "\"https://flowinity.com\"")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -40,6 +58,12 @@ android {
debug {
buildConfigField("String", "SERVER_URL", "\"http://192.168.0.12:34582\"")
}
+ create("benchmark") {
+ initWith(buildTypes.getByName("release"))
+ signingConfig = signingConfigs.getByName("debug")
+ matchingFallbacks += listOf("release")
+ isDebuggable = false
+ }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
@@ -63,15 +87,16 @@ android {
}
dependencies {
+ implementation("com.github.skydoves:landscapist-glide:2.5.2")
+// implementation("com.apollographql.apollo3:apollo-runtime:3.8.6")
// Firebase
- implementation(platform("com.google.firebase:firebase-bom:32.1.1"))
- implementation("com.google.firebase:firebase-messaging-ktx:23.1.2")
- implementation("com.google.firebase:firebase-analytics-ktx:21.3.0")
+ implementation(platform("com.google.firebase:firebase-bom:33.5.1"))
+ implementation("com.google.firebase:firebase-messaging-ktx:23.2.0")
+ implementation("com.google.firebase:firebase-analytics-ktx:22.1.2")
// TPU
- implementation("androidx.core:core-splashscreen:1.0.1")
- implementation("com.github.wax911:android-emojify:1.7.1")
- implementation("androidx.activity:activity:1.7.2")
+ implementation(libs.androidx.core.splashscreen)
+ implementation(libs.androidx.activity)
implementation("com.google.accompanist:accompanist-permissions:0.31.4-beta")
implementation("io.mhssn:colorpicker:1.0.0")
implementation("com.google.accompanist:accompanist-insets:0.22.0-rc")
@@ -80,33 +105,34 @@ dependencies {
implementation("io.noties.markwon:ext-tables:4.6.2")
implementation("io.noties.markwon:html:4.6.2")
implementation("io.noties.markwon:linkify:4.6.2")
- implementation("io.sentry:sentry-android:6.23.0")
- implementation("io.sentry:sentry-compose-android:6.23.0")
+ implementation("io.sentry:sentry-okhttp:8.20.0")
+ implementation("io.sentry:sentry-android:8.20.0")
+ implementation("io.sentry:sentry-compose-android:8.20.0")
implementation("androidx.compose.runtime:runtime-tracing:1.0.0-alpha03")
- implementation("io.coil-kt:coil-gif:2.4.0")
- implementation("io.coil-kt:coil-compose:2.4.0")
implementation("com.github.X1nto:OverlappingPanelsCompose:1.2.0")
- implementation("io.coil-kt:coil:2.3.0")
implementation("com.github.jeziellago:compose-markdown:0.3.3")
- implementation("com.github.bumptech.glide:compose:1.0.0-alpha.1")
- implementation("com.squareup.retrofit2:retrofit:2.9.0")
- implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
- implementation("com.squareup.moshi:moshi:1.12.0")
- implementation("com.squareup.moshi:moshi-kotlin:1.12.0")
- implementation("com.github.bumptech.glide:glide:4.16.0-SNAPSHOT")
+ implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
+ implementation("com.squareup.retrofit2:retrofit:3.0.0")
+ implementation("com.squareup.retrofit2:converter-moshi:3.0.0")
+ implementation("com.squareup.moshi:moshi:1.15.2")
+ implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
+ implementation("com.github.bumptech.glide:glide:5.0.4")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
+ implementation("io.coil-kt:coil:2.7.0")
+ implementation("io.coil-kt:coil-compose:2.7.0")
+ implementation("io.coil-kt:coil-gif:2.7.0")
implementation("androidx.core:core-ktx:1.10.1")
- implementation("com.google.code.gson:gson:2.10.1")
- implementation("com.squareup.retrofit2:converter-gson:2.9.0")
+ implementation("com.google.code.gson:gson:2.13.1")
+ implementation("com.squareup.retrofit2:converter-gson:3.0.0")
implementation("io.socket:socket.io-client:2.1.0") {
exclude("org.json", "json")
}
// Android
- implementation("androidx.navigation:navigation-compose:2.6.0")
+ implementation("androidx.navigation:navigation-compose:2.9.3")
// Material Design 3
- implementation("androidx.compose.material3:material3:1.2.0-alpha03")
+ implementation("androidx.compose.material3:material3:1.3.2")
// For SwipeableState
implementation("androidx.compose.material:material:1.4.3")
// or only import the main APIs for the underlying toolkit systems,
@@ -146,4 +172,8 @@ dependencies {
androidTestImplementation(libs.ui.test.junit4)
debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest)
+}
+
+configurations.all {
+ exclude(group = "io.sentry", module = "sentry-android-okhttp")
}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..c0c4397 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -18,4 +18,25 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+-dontwarn kotlinx.serialization.DeserializationStrategy
+-dontwarn kotlinx.serialization.KSerializer
+-dontwarn kotlinx.serialization.Serializable
+-dontwarn kotlinx.serialization.builtins.BuiltinSerializersKt
+-dontwarn kotlinx.serialization.internal.ArrayListSerializer
+-dontwarn kotlinx.serialization.internal.GeneratedSerializer
+-dontwarn kotlinx.serialization.internal.PluginGeneratedSerialDescriptor
+-dontwarn kotlinx.serialization.internal.StringSerializer
+-dontwarn kotlinx.serialization.json.Json
+-dontwarn kotlinx.serialization.json.JsonBuilder
+-dontwarn kotlinx.serialization.json.JsonKt
+-dontwarn kotlinx.serialization.json.JvmStreamsKt
+-keepclassmembers,allowobfuscation class * {
+ @com.google.gson.annotations.SerializedName ;
+}
+
+-keep class * {
+ @com.google.gson.annotations.SerializedName ;
+}
+-keepnames class com.fasterxml.jackson.databind.** { *; }
+-dontwarn com.fasterxml.jackson.databind.**
\ No newline at end of file
diff --git a/app/src/main/graphql/schema.sdl b/app/src/main/graphql/schema.sdl
new file mode 100644
index 0000000..b2c66f8
--- /dev/null
+++ b/app/src/main/graphql/schema.sdl
@@ -0,0 +1,1765 @@
+type Query {
+ currentUser: User
+ user(input: UserProfileInput!): PartialUserPublic
+ trackedUsers: [PartialUserFriend!]!
+ trackedUserIds: [Float!]!
+ coreState: CoreState!
+ setupStep: Int!
+ experiments: [ExperimentType!]!
+ weather: Weather!
+ collections(input: UserCollectionsInput): PaginatedCollectionResponse
+ collection(input: CollectionInput!): Collection
+ domains: [Domain!]!
+ gallery(input: GalleryInput!): PaginatedUploadResponse!
+ chats(input: ChatsInput): [Chat!]!
+ chat(input: ChatInput!): Chat!
+ availableChatPermissions: [ChatPermission!]!
+ lookupBotPrefix(input: LookupPrefixInput!): Prefix!
+ workspaces: [Workspace!]!
+ note(input: NoteInput!): Note
+ friends(input: FriendsInput): [Friend!]!
+ messages(input: InfiniteMessagesInput!): [Message!]!
+ messagesPaged(input: PagedMessagesInput!): PaginatedMessageResponse!
+ autoCollects(input: UserCollectionsInput!): PaginatedCollectionResponse!
+ blockedUsers: [BlockedUser!]!
+ chatInvite(input: InviteInput!): ChatInvite
+ mailboxes: [ListResponse!]!
+ unreadMail: Int!
+ getMail(input: GetMailInput!): JSON!
+ oauthApps: [OauthApp!]!
+ oauthApp(input: MyAppInput!): OauthApp!
+ oauthAppConsent(input: MyAppInput!): OauthConsentApp!
+ getAuthorizedApps: [OauthApp!]!
+ userEmoji: [ChatEmoji!]!
+ chatAuditLog(input: AuditLogInput!): PaginatedChatAuditLogResponse!
+}
+
+type User {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ username: String!
+ email: String!
+ description: String
+ administrator: Boolean!
+ darkTheme: Boolean!
+ emailVerified: Boolean!
+ banned: Boolean!
+ inviteId: Float
+ discordPrecache: Boolean!
+ avatar: String
+ subdomainId: Float @deprecated(reason: "Subdomains are no longer available as of TPUv2/NEXT.")
+ domainId: Float!
+ totpEnable: Boolean!
+
+ """How much the user has uploaded in bytes."""
+ quota: Float!
+ uploadNameHidden: Boolean! @deprecated(reason: "Hidden upload usernames are no longer available as of TPUv2/NEXT.")
+ invisibleURLs: Boolean! @deprecated(reason: "Invisible URLs are no longer available as of TPUv2/NEXT.")
+ moderator: Boolean!
+
+ """
+ Subscriptions are no longer used as they were in TPUv1, and are now used to store metadata for permanent Gold subscriptions.
+ """
+ subscriptionId: Float
+ fakePath: String @deprecated(reason: "Fake paths are no longer available as of TPUv2/NEXT.")
+ themeId: Float! @deprecated(reason: "Replaced with `themeEngine`, used in legacy clients such as legacy.privateuploader.com.")
+ itemsPerPage: Float!
+
+ """UserV2 banner."""
+ banner: String
+
+ """Ability to login with more then 1 password with different scopes."""
+ alternatePasswords: [AlternatePassword!]
+
+ """User status/presence shown to other users."""
+ status: UserStatus!
+
+ """
+ User status/presence that has `invisible` and is shown to the current user.
+ """
+ storedStatus: UserStoredStatus!
+ weatherUnit: String!
+ themeEngine: ThemeEngine
+ insights: UserInsights!
+ profileLayout: ProfileLayout!
+
+ """Collections that are excluded from the Collections filter in Gallery."""
+ excludedCollections: [Float!]
+ language: String!
+ publicProfile: Boolean!
+
+ """
+ How much the user has donated to PrivateUploader. (Likely unused in unofficial instances.)
+ """
+ xp: Float!
+ privacyPolicyAccepted: Boolean
+
+ """The user's name color in Communications."""
+ nameColor: String
+
+ """Whether the user is a bot user."""
+ bot: Boolean!
+ plan: Plan
+ domain: Domain
+ subscription: TPUSubscription
+ experiments: [Experiment!]
+ badges: [Badge!]!
+ autoCollectRules: [AutoCollectRule!]!
+
+ """
+ The user's scopes assigned to the API key used. In format like `user.view,user.modify` which belong to `Scope`.
+ """
+ scopes: String
+ stats: Stats
+ oauthAppId: String
+ nickname: [FriendNickname!]
+ integrations: [Integration!]!
+ notifications: [Notification!]!
+ sessions: [Session!]!
+
+ """How many AutoCollect approvals the user needs to approve/reject."""
+ pendingAutoCollects: Float
+ friends: [Friend!]!
+ friend: FriendStatus!
+}
+
+scalar Date
+
+type AlternatePassword {
+ scopes: String!
+ totp: Boolean!
+ name: String!
+}
+
+"""User status/presence shown to other users."""
+enum UserStatus {
+ ONLINE
+ IDLE
+ OFFLINE
+ BUSY
+ UNKNOWN
+}
+
+"""
+User status/presence that has `invisible` and is shown to the current user.
+"""
+enum UserStoredStatus {
+ ONLINE
+ IDLE
+ BUSY
+ INVISIBLE
+}
+
+type ThemeEngine {
+ theme: ThemeEngineThemes!
+ fluidGradient: Boolean!
+ gradientOffset: String!
+ defaults: ThemeEngineThemes
+ version: Float!
+ deviceSync: Boolean!
+ showOnProfile: Boolean!
+ baseTheme: String!
+ customCSS: String
+}
+
+type ThemeEngineThemes {
+ dark: ThemeEngineTheme!
+ light: ThemeEngineTheme!
+ amoled: ThemeEngineTheme!
+}
+
+type ThemeEngineTheme {
+ colors: ThemeEngineColors!
+ dark: Boolean
+}
+
+type ThemeEngineColors {
+ primary: String!
+ logo1: String!
+ logo2: String!
+ secondary: String!
+ accent: String!
+ error: String!
+ info: String!
+ success: String!
+ warning: String!
+ card: String!
+ toolbar: String!
+ sheet: String!
+ text: String!
+ dark: String!
+ gold: String!
+ background: String!
+ background2: String!
+}
+
+"""Insights privacy preference."""
+enum UserInsights {
+ EVERYONE
+ FRIENDS
+ NOBODY
+}
+
+type ProfileLayout {
+ layout: ProfileLayoutObject!
+ config: ProfileLayoutConfig!
+ version: Float!
+}
+
+type ProfileLayoutObject {
+ columns: [ProfileLayoutColumn!]!
+}
+
+type ProfileLayoutColumn {
+ rows: [ProfileLayoutComponent!]!
+}
+
+type ProfileLayoutComponent {
+ name: String!
+ id: String!
+ props: ProfileLayoutProps
+}
+
+type ProfileLayoutProps {
+ height: Float
+ children: [ProfileLayoutComponent!]
+ friendsOnly: Boolean
+ display: Float
+ type: String
+ links: [ProfileLayoutPropLink!]
+}
+
+type ProfileLayoutPropLink {
+ name: String!
+ url: String!
+ color: String!
+}
+
+type ProfileLayoutConfig {
+ containerMargin: Float
+ showStatsSidebar: Boolean!
+}
+
+type Plan {
+ id: Int!
+ name: String!
+ quotaMax: Float!
+ price: Float! @deprecated(reason: "Plans are unused in TPUv2+.")
+ features: String @deprecated(reason: "Plans are unused in TPUv2+.")
+ color: String
+ internalName: String!
+ purchasable: Boolean! @deprecated(reason: "Plans are unused in TPUv2+.")
+ internalFeatures: String @deprecated(reason: "Plans are unused in TPUv2+.")
+ icon: String!
+}
+
+type Domain {
+ id: Float!
+ domain: String!
+ userId: Float!
+ DNSProvisioned: Boolean! @deprecated(reason: "Use `active` instead.")
+ active: Boolean!
+ zone: String @deprecated(reason: "Cloudflare integration was removed in TPUv2.")
+ advanced: Float @deprecated(reason: "Cloudflare integration was removed in TPUv2.")
+ subdomains: Boolean! @deprecated(reason: "Subdomains were removed in TPUv2.")
+ subdomainsCreate: Boolean! @deprecated(reason: "Subdomains were removed in TPUv2.")
+ customUserEligibility: [Int!] @deprecated(reason: "Granular user control was removed in TPUv2.")
+ restricted: String! @deprecated(reason: "Granular user control was removed in TPUv2.")
+ user: PartialUserBase!
+
+ """Only populated in some admin contexts"""
+ users: [PartialUserBase!]
+}
+
+type PartialUserBase {
+ username: String!
+ id: Float!
+ createdAt: Date!
+ administrator: Boolean!
+ moderator: Boolean!
+ avatar: String
+ bot: Boolean!
+}
+
+type TPUSubscription {
+ planId: Float!
+ userId: Float!
+ price: Float!
+ cancelled: Boolean!
+ paymentId: Float!
+ expiredAt: DateTimeISO!
+ cancelledAt: DateTimeISO!
+ metadata: SubscriptionMetadata!
+ user: PartialUserBase!
+}
+
+"""
+A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.This scalar is serialized to a string in ISO 8601 format and parsed from a string in ISO 8601 format.
+"""
+scalar DateTimeISO
+
+type SubscriptionMetadata {
+ hours: Float!
+}
+
+type Experiment {
+ key: String!
+ value: String!
+ userId: Float!
+ user: PartialUserBase!
+}
+
+type Badge {
+ id: Int!
+ name: String!
+ description: String
+ tooltip: String
+ image: String
+ icon: String
+ color: String
+ unlocked: Boolean!
+ priority: Float
+ plan: Plan!
+ users: [PartialUserBase!]!
+}
+
+type AutoCollectRule {
+ id: Int!
+ name: String!
+ enabled: Boolean!
+ collectionId: Float!
+ requireApproval: Boolean!
+ rules: [AutoCollectParentRule!]!
+}
+
+type AutoCollectParentRule {
+ id: Float!
+ rules: [SubRule!]!
+}
+
+type SubRule {
+ id: Float!
+ type: String!
+ value: String!
+ operator: String!
+}
+
+type Stats {
+ users: Float!
+ announcements: Float!
+ usage: Float!
+ collections: Float!
+ collectionItems: Float!
+ uploadGraph: DataLabelsGraph!
+ messageGraph: DataLabelsGraph!
+ pulseGraph: DataLabelsGraph!
+ uploads: Float!
+ pulse: Float!
+ pulses: Float!
+ docs: Float!
+}
+
+type DataLabelsGraph {
+ data: [Float!]!
+ labels: [String!]!
+}
+
+type FriendNickname {
+ id: Int!
+ userId: Float!
+ createdAt: Date!
+ updatedAt: Date!
+ friendId: Float!
+ nickname: String!
+ user: PartialUserBase!
+ friend: PartialUserBase!
+}
+
+type Integration {
+ id: Int!
+ userId: Float!
+ type: String!
+ expiresAt: DateTimeISO
+ createdAt: Date!
+ updatedAt: Date!
+ providerUserId: Float
+ providerUsername: String
+ providerUserCache: JSON
+ error: String
+ user: PartialUserBase!
+}
+
+"""
+The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
+"""
+scalar JSON
+
+type Notification {
+ id: Int!
+ message: String!
+ userId: Float!
+ dismissed: Boolean!
+ route: String
+ createdAt: Date!
+ updatedAt: Date!
+ user: PartialUserBase
+}
+
+type Session {
+ id: Int!
+ userId: Float!
+ scopes: String!
+ type: String!
+ expiredAt: DateTimeISO
+ name: String
+ info: SessionInfo
+ oauthAppId: String
+}
+
+type SessionInfo {
+ accessedFrom: [AccessedFrom!]!
+}
+
+type AccessedFrom {
+ ip: String!
+ userAgent: String
+ isp: String
+ location: String
+ date: String!
+ asn: Float
+}
+
+type Friend {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ status: FriendStatus!
+ userId: Int!
+ friendId: Int!
+ user: PartialUserFriend!
+ otherUser: PartialUserFriend!
+}
+
+"""Friend request status."""
+enum FriendStatus {
+ NONE
+ INCOMING
+ OUTGOING
+ ACCEPTED
+}
+
+type PartialUserFriend {
+ username: String!
+ id: Float!
+ createdAt: Date!
+ administrator: Boolean!
+ moderator: Boolean!
+ avatar: String
+ bot: Boolean!
+ status: UserStatus!
+ nameColor: String
+ nickname: FriendNickname
+ blocked: Boolean
+}
+
+type PartialUserPublic {
+ bot: Boolean!
+ username: String!
+ id: Float!
+ createdAt: Date!
+ administrator: Boolean!
+ moderator: Boolean!
+ avatar: String
+ badges: [Badge!]!
+ banned: Boolean!
+ banner: String
+ description: String
+ friend: FriendStatus
+ friends: [Friend!]
+ insights: UserInsights!
+ plan: Plan!
+ platforms: [Platform!]
+ profileLayout: ProfileLayout
+ publicProfile: Boolean!
+ quota: Float!
+ stats: Stats
+ themeEngine: ThemeEngine
+ xp: Float
+ notifications: [Notification!]!
+ integrations: [Integration!]!
+ domain: [Domain!]!
+ subscription: [TPUSubscription!]!
+ autoCollectRules: AutoCollectRule!
+}
+
+type Platform {
+ platform: String!
+ id: String!
+ lastSeen: String!
+ status: String!
+}
+
+input UserProfileInput {
+ id: Float
+ username: String
+}
+
+type CoreState {
+ connection: Connection!
+ name: String!
+
+ """Whether the app is running in production mode."""
+ release: String!
+ hostname: String!
+ hostnameWithProtocol: String!
+ announcements: [Announcement!]!
+ stats: CoreStats!
+ maintenance: Maintenance!
+ registrations: Boolean!
+
+ """
+ Whether the TPU instance is the officially run instance on privateuploader.com. This can be enabled on any instance but can enable unwanted features.
+ """
+ officialInstance: Boolean!
+ providers: Providers!
+
+ """Workspaces Note ID for the Terms of Service."""
+ termsNoteId: String
+
+ """Workspaces Note ID for the Privacy Policy."""
+ privacyNoteId: String
+
+ """List of enabled features for TPU instance"""
+ features: Features!
+ inviteAFriend: Boolean!
+
+ """
+ List of domains that are pre-trusted for user-generated hyperlinks such as Communications messages which don't require a confirmation to proceed.
+ """
+ preTrustedDomains: [String!]!
+ hostnames: [String!]!
+ _redis: String!
+ server: String!
+ finishedSetup: Boolean!
+ domain: String!
+
+ """Uptime of the TPU Server in seconds."""
+ uptime: Float!
+
+ """Uptime of the system in seconds."""
+ uptimeSys: Float!
+ commitVersion: String!
+ weather: Weather!
+}
+
+type Connection {
+ ip: String!
+ whitelist: Boolean! @deprecated(reason: "No longer used in v4.")
+}
+
+type Announcement {
+ id: Int!
+ userId: Float!
+ content: String!
+ type: String
+ createdAt: Date
+ updatedAt: Date
+ user: PartialUserBase!
+}
+
+type CoreStats {
+ users: Float!
+ announcements: Float!
+ usage: Float!
+ collections: Float!
+ collectionItems: Float!
+ uploadGraph: DataLabelsGraph!
+ messageGraph: DataLabelsGraph!
+ pulseGraph: DataLabelsGraph!
+ uploads: Float!
+ pulse: Float!
+ pulses: Float!
+ docs: Float!
+ invites: Float!
+ inviteMilestone: Float!
+ messages: Float!
+ chats: Float!
+ hours: [String!]
+}
+
+type Maintenance {
+ enabled: Boolean!
+ message: String
+ statusPage: String
+}
+
+type Providers {
+ anilist: Boolean!
+ lastfm: Boolean!
+ mal: Boolean!
+}
+
+type Features {
+ communications: Boolean!
+ collections: Boolean!
+ autoCollects: Boolean!
+ workspaces: Boolean!
+ insights: Boolean!
+}
+
+type Weather {
+ icon: String
+ temp: Float
+ feels_like: Float
+ temp_min: Float
+ temp_max: Float
+ pressure: Float
+ humidity: Float
+ wind_speed: Float
+ wind_deg: Float
+ clouds: Float
+ visibility: Float
+ error: Boolean
+ cached: Boolean
+ description: String
+ main: String
+ location: String
+}
+
+type ExperimentType {
+ id: String!
+ value: Int!
+ description: String
+ createdAt: Date
+ refresh: Boolean
+}
+
+type PaginatedCollectionResponse {
+ items: [Collection!]!
+ pager: Pager!
+}
+
+type Collection {
+ id: Int!
+ name: String!
+ image: String
+ userId: Float!
+ shareLink: String
+ user: PartialUserBase!
+ preview: CollectionItem
+ users: [CollectionUser!]!
+ recipient: CollectionUser
+ autoCollectApprovals: [AutoCollectApproval!]!
+ shared: Boolean
+ itemCount: Int
+ attachments: [Upload!]!
+ permissionsMetadata: PermissionsMetadata!
+}
+
+type CollectionItem {
+ id: Int!
+ collectionId: Float!
+ attachmentId: Float!
+ userId: Float!
+
+ """
+ Used to prevent duplicates by forming `uploadId-collectionId`. Can be null for items created before October 2022.
+ """
+ identifier: String
+ pinned: Boolean!
+ user: PartialUserBase!
+ collection: Collection!
+ attachment: Upload!
+}
+
+type Upload {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ attachment: String!
+ userId: Float!
+ name: String
+ originalFilename: String
+ type: String!
+ urlRedirect: String @deprecated(reason: "URL redirects were removed in TPUv2/NEXT.")
+ fileSize: Float!
+
+ """
+ Non-deletable items are used for profile pictures, banners, etc and are not visible in the Gallery page.
+ """
+ deletable: Boolean!
+
+ """This is used for OCR scanned text from images."""
+ textMetadata: String
+ user: PartialUserBase
+ item: CollectionItem
+ collections: [Collection!]!
+ items: [CollectionItem!]!
+ starred: Star
+ autoCollectApproval: AutoCollectApproval
+}
+
+type Star {
+ id: Int!
+ userId: Float!
+ attachmentId: Float!
+ user: PartialUserBase!
+ attachment: Upload!
+}
+
+type AutoCollectApproval {
+ id: Int!
+ autoCollectRuleId: Float!
+ uploadId: Float!
+ collectionId: Float!
+ userId: Float!
+ approved: Boolean!
+ user: PartialUserBase
+ collection: Collection
+ autoCollectRule: AutoCollectRule
+ attachment: Upload
+}
+
+type CollectionUser {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ collectionId: Float!
+ read: Boolean!
+ write: Boolean!
+ configure: Boolean!
+ accepted: Boolean!
+ recipientId: Float
+ senderId: Float
+ identifier: String
+ collection: Collection
+ user: PartialUserBase
+ sender: PartialUserBase
+}
+
+type PermissionsMetadata {
+ write: Boolean!
+ read: Boolean!
+ configure: Boolean!
+}
+
+type Pager {
+ totalItems: Int!
+ currentPage: Int!
+ pageSize: Int!
+ totalPages: Int!
+ startPage: Int!
+ endPage: Int!
+ startIndex: Int!
+ endIndex: Int!
+ pages: [Float!]!
+}
+
+input UserCollectionsInput {
+ filter: [CollectionFilter!]! = [ALL]
+ search: String
+ limit: Float! = 24
+ page: Float! = 1
+}
+
+"""The type of collection"""
+enum CollectionFilter {
+ ALL
+ WRITE
+ READ
+ CONFIGURE
+ SHARED
+ OWNED
+}
+
+input CollectionInput {
+ id: Int
+ shareLink: String
+}
+
+type PaginatedUploadResponse {
+ items: [Upload!]!
+ pager: Pager!
+}
+
+input GalleryInput {
+ search: String = ""
+ page: Int = 1
+ limit: Int
+ filters: [GalleryFilter!] = [ALL]
+ sort: GallerySort = CREATED_AT
+ order: GalleryOrder = DESC
+ type: GalleryType = PERSONAL
+
+ """Requires Type to be COLLECTION"""
+ collectionId: Int
+
+ """Requires Type to be COLLECTION"""
+ shareLink: String
+ advanced: [SearchModeInput!]
+}
+
+"""The filter to apply to the gallery request"""
+enum GalleryFilter {
+ ALL
+ OWNED
+ SHARED
+ NO_COLLECTION
+ IMAGES
+ VIDEOS
+ GIFS
+ AUDIO
+ TEXT
+ OTHER
+ PASTE
+ INCLUDE_METADATA
+ INCLUDE_DELETABLE
+}
+
+"""The sort to apply to the gallery request"""
+enum GallerySort {
+ CREATED_AT
+ UPDATED_AT
+ NAME
+ SIZE
+}
+
+"""The order to apply to the gallery request"""
+enum GalleryOrder {
+ ASC
+ DESC
+}
+
+"""
+The type of gallery request, for example if it's the personal gallery page, or a Collection
+"""
+enum GalleryType {
+ PERSONAL
+ STARRED
+ COLLECTION
+ AUTO_COLLECT
+}
+
+input SearchModeInput {
+ mode: GallerySearchMode!
+ value: String
+}
+
+"""The advanced search mode."""
+enum GallerySearchMode {
+ AFTER
+ DURING
+ USER
+ SIZE
+ NAME
+ META
+ TYPE
+ COLLECTION
+ BEFORE
+ ORDER
+}
+
+type Chat {
+ id: Int!
+ type: String!
+ name: String!
+
+ """
+ Null if the chat is owned by a Colubrina legacy user, or the account was deleted.
+ """
+ userId: Float
+ icon: String
+ createdAt: DateTimeISO!
+ updatedAt: DateTimeISO!
+
+ """This is used if the chat is owned by a Colubrina legacy user."""
+ legacyUserId: Float @deprecated(reason: "Use userId instead.")
+
+ """
+ Null if the chat is owned by a Colubrina legacy user, or the account was deleted.
+ """
+ user: PartialUserBase
+
+ """This is used if the chat is owned by a Colubrina legacy user."""
+ legacyUser: PartialUserBase @deprecated(reason: "Use user instead.")
+ association: ChatAssociation
+ users: [ChatAssociation!]!
+ description: String
+ background: String
+ unread: Int
+ _redisSortDate: String
+ ranks: [ChatRank!]!
+ messages: [Message!]!
+
+ """
+ Array is empty if you don't have the `OVERVIEW` permission in the chat.
+ """
+ invites: [ChatInvite!]!
+ emoji: [ChatEmoji!]
+ recipient: PartialUserBase
+}
+
+type ChatAssociation {
+ id: Int!
+ chatId: Float!
+ userId: Float
+ rank: String! @deprecated(reason: "`ChatRank` has replaced legacy rank for granular permission control.")
+ lastRead: Float
+ createdAt: Date!
+ notifications: String!
+
+ """Used for legacy Colubrina accounts."""
+ legacyUserId: Float @deprecated(reason: "Use `userId` instead.")
+
+ """
+ Only true/false for current user, null for other ChatAssociations. This determines whether the chat is visible in the sidebar (open or closed).
+ """
+ hidden: Boolean
+ inviteUsed: String
+ invite: ChatInvite
+
+ """Used for user virtual which falls back to a Colubrina account."""
+ tpuUser: PartialUserBase
+
+ """Used for legacy Colubrina accounts."""
+ legacyUser: PartialUserBase @deprecated(reason: "Use `user` instead.")
+ user: PartialUserBase
+ ranks: [ChatRank!]!
+ ranksMap: [String!]!
+ permissions: [String!]!
+}
+
+type ChatInvite {
+ id: String!
+ userId: Int!
+ chatId: Int!
+
+ """
+ Automatically assigns rank to user when joining. If unset the backend will set the `managed` Members role.
+ """
+ rankId: String
+ createdAt: Date!
+ updatedAt: Date!
+ expiredAt: Date
+ invalidated: Boolean!
+ user: PartialUserBase!
+ chat: Chat!
+ rank: ChatRank
+}
+
+type ChatRank {
+ id: String!
+ color: String
+ name: String!
+ userId: Int!
+ createdAt: Date
+ chatId: Int!
+ updatedAt: Date
+ managed: Boolean!
+ index: Int!
+ permissions: [ChatPermission!]!
+ permissionsMap: [String!]!
+ associations: [ChatAssociation!]!
+}
+
+type ChatPermission {
+ id: String!
+ description: String!
+ name: String!
+ createdAt: Date
+ updatedAt: Date
+ group: RankPermissionGroup!
+}
+
+"""
+The category that the permission is categorized into for Communications ranks.
+"""
+enum RankPermissionGroup {
+ ADMIN
+ MANAGE
+ GENERAL
+ OPTIONS
+}
+
+type Message {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ chatId: Int!
+ userId: Int
+ content: String
+ type: MessageType
+ embeds: [Embed!]!
+ edited: Boolean!
+ editedAt: Date
+ replyId: Int
+ legacyUserId: Int
+ pinned: Boolean!
+ tpuUser: PartialUserBase
+ reply: Message
+ legacyUser: PartialUserBase
+ user: PartialUserBase
+ readReceipts: [ChatAssociation!]!
+ chat: Chat!
+ emoji: [ChatEmoji!]
+}
+
+"""
+The type of message. Can be null for legacy (Colubrina) messages where `MESSAGE` should be inferred.
+"""
+enum MessageType {
+ MESSAGE
+ LEAVE
+ JOIN
+ PIN
+ ADMINISTRATOR
+ RENAME
+ SYSTEM
+}
+
+type Embed {
+ type: String!
+ data: JSON
+}
+
+type ChatEmoji {
+ id: String!
+ userId: Int!
+ chatId: Int!
+ icon: String
+ name: String
+ deleted: Boolean!
+ createdAt: Date!
+ updatedAt: Date!
+}
+
+input ChatsInput {
+ hidden: Boolean! = false
+}
+
+input ChatInput {
+ associationId: Int
+ chatId: Int
+}
+
+type Prefix {
+ prefix: String!
+ commands: [LookupPrefix!]!
+}
+
+type LookupPrefix {
+ command: String!
+ description: String!
+ botId: Float!
+}
+
+input LookupPrefixInput {
+ chatAssociationId: Float!
+ prefix: String!
+}
+
+type Workspace {
+ id: Int!
+ name: String!
+ userId: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ icon: String
+ user: PartialUserBase!
+ folders: [WorkspaceFolder!]!
+ users: [WorkspaceUser!]!
+}
+
+type WorkspaceFolder {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ name: String!
+ workspaceId: Float!
+ folderId: Float
+ children: [Note!]!
+ workspace: Workspace!
+}
+
+type Note {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ name: String!
+ data: WorkspaceNote
+ metadata: WorkspaceNoteMetadata
+ workspaceFolderId: Float!
+ shareLink: String
+ versions: [NoteVersion!]!
+ permissions: NotePermissionsMetadata
+}
+
+type WorkspaceNote {
+ version: String
+ blocks: JSON
+ time: Float
+}
+
+type WorkspaceNoteMetadata {
+ version: String
+}
+
+type NoteVersion {
+ id: String!
+ noteId: Int!
+ userId: Int!
+ data: WorkspaceNote
+}
+
+type NotePermissionsMetadata {
+ modify: Boolean!
+ read: Boolean!
+ configure: Boolean!
+}
+
+type WorkspaceUser {
+ id: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ workspaceId: Float!
+ read: Boolean!
+ write: Boolean!
+ configure: Boolean!
+ accepted: Boolean!
+ recipientId: Int!
+ senderId: Int!
+
+ """The unique identifier between the User and the Workspace."""
+ identifier: String
+ workspace: Workspace!
+ user: PartialUserBase!
+ sender: PartialUserBase!
+}
+
+input NoteInput {
+ id: Float
+ shareLink: String
+}
+
+input FriendsInput {
+ status: FriendStatus
+}
+
+input InfiniteMessagesInput {
+ associationId: Int!
+ position: ScrollPosition
+ search: MessagesSearch
+ limit: Int! = 50
+ offset: Int
+}
+
+"""The position to retrieve messages from based on the `offset`."""
+enum ScrollPosition {
+ TOP
+ BOTTOM
+}
+
+input MessagesSearch {
+ query: String
+ userId: Int
+ before: Date
+ after: Date
+ pins: Boolean
+}
+
+type PaginatedMessageResponse {
+ items: [Message!]!
+ pager: Pager!
+}
+
+input PagedMessagesInput {
+ associationId: Int!
+ position: ScrollPosition
+ search: MessagesSearch
+ limit: Int! = 50
+ page: Int! = 1
+}
+
+type BlockedUser {
+ id: String!
+ userId: Int!
+ createdAt: Date!
+ updatedAt: Date!
+ blockedUserId: Int!
+
+ """
+ To the blocked user it appears as though they're unblocked, however the blocker will not receive any messages from them, and their messages will be hidden inside of group chats.
+ """
+ silent: Boolean!
+ user: PartialUserBase
+ blockedUser: PartialUserBase
+}
+
+input InviteInput {
+ inviteId: String!
+}
+
+type ListResponse {
+ path: String!
+ name: String!
+ delimiter: String!
+ flags: [String!]!
+ specialUse: String
+ listed: Boolean!
+ subscribed: Boolean
+}
+
+input GetMailInput {
+ userId: Float!
+ mailbox: String!
+ page: Float
+}
+
+type OauthApp {
+ id: String!
+ name: String!
+ icon: String
+ shortCode: String
+ verified: Boolean!
+ redirectUri: String
+ secret: String
+ description: String
+ scopes: String!
+ userId: Float!
+ botId: Float
+ private: Boolean!
+ user: PartialUserBase!
+ bot: PartialUserBase
+ oauthUsers: [OauthUser!]!
+ token: String
+}
+
+type OauthUser {
+ id: String!
+ userId: Float!
+ oauthAppId: String!
+ active: Boolean!
+ createdAt: Date!
+ updatedAt: Date!
+ manage: Boolean!
+ user: PartialUserBase!
+}
+
+input MyAppInput {
+ id: String!
+}
+
+type OauthConsentApp {
+ id: String!
+ name: String!
+ icon: String
+ shortCode: String
+ verified: Boolean!
+ redirectUri: String
+ description: String
+ scopes: String!
+ userId: Float!
+ botId: Float
+ private: Boolean!
+ user: PartialUserBase!
+ bot: PartialUserBase
+ token: String
+}
+
+type PaginatedChatAuditLogResponse {
+ items: [ChatAuditLog!]!
+ pager: Pager!
+}
+
+type ChatAuditLog {
+ id: String!
+ userId: Int!
+ chatId: Int!
+ category: AuditLogCategory!
+ actionType: AuditLogActionType!
+ message: String!
+ createdAt: Date!
+ updatedAt: Date!
+}
+
+"""Used for chat audit log."""
+enum AuditLogCategory {
+ USER
+ RANK
+ MESSAGE
+ INVITE
+ PIN_MESSAGE
+ BOT
+ SETTINGS
+ EMOJI
+}
+
+"""
+Used for chat audit log to determine what type of action was performed.
+"""
+enum AuditLogActionType {
+ MODIFY
+ ADD
+ REMOVE
+}
+
+input AuditLogInput {
+ associationId: Float!
+ page: Float! = 1
+ limit: Float! = 24
+}
+
+type Mutation {
+ updateUser(input: UpdateUserInput!): Boolean!
+ login(input: LoginInput!): LoginResponse!
+ logout: Boolean!
+ register(input: RegisterInput!): LoginResponse!
+ setExperiment(input: SetExperimentInput!): Experiment!
+ upload(file: File!): Upload!
+ createChat(input: CreateChatInput!): Chat!
+ updateChat(input: UpdateChatInput!): Chat!
+ deleteGroup(input: DangerZoneChatInput!): GenericSuccessObject!
+ transferGroupOwnership(input: TransferOwnershipInput!): Chat!
+ addChatUsers(input: AddChatUser!): GenericSuccessObject!
+ toggleUserRank(input: AddRank!): GenericSuccessObject!
+ leaveChat(input: LeaveChatInput!): GenericSuccessObject!
+ joinChatFromInvite(input: JoinChatFromInviteInput!): ChatAssociation!
+ addBotToChat(input: AddBotToChatInput!): ChatAssociation!
+
+ """Create a new Workspace Folder."""
+ createWorkspaceFolder(input: CreateWorkspaceFolderInput!): WorkspaceFolder!
+
+ """Create workspace"""
+ createWorkspace(
+ """Name of workspace"""
+ input: String!
+ ): Workspace!
+
+ """Delete a Note."""
+ deleteWorkspaceItem(input: DeleteWorkspaceItemInput!): Boolean!
+ saveNote(input: SaveNoteInput!): Note!
+ createNote(input: CreateNoteInput!): Note!
+
+ """Toggle the ShareLink for a Note."""
+ toggleNoteShare(
+ """ID of Note"""
+ input: Int!
+ ): Note!
+ sendMessage(input: SendMessageInput!): Message!
+ updateChatRank(input: UpdateRank!): ChatRank!
+ addChatRank(input: CreateRank!): ChatRank!
+ updateChatRankOrder(input: UpdateRankOrder!): [ChatRank!]!
+ deleteChatRank(input: DeleteRank!): GenericSuccessObject!
+ adminMigrateLegacyRanksForChat: GenericSuccessObject!
+ blockUser(input: BlockUserInput!): GenericSuccessObject!
+ createChatInvite(input: CreateInviteInput!): ChatInvite!
+ oauthAppAuthorize(input: AuthorizeAppInput!): AuthorizeAppResponse!
+ oauthAppDeauthorize(input: MyAppInput!): GenericSuccessObject!
+ createOauthApp(input: CreateAppInput!): OauthApp!
+ deleteOauthApp(input: MyAppInput!): GenericSuccessObject!
+ resetOauthSecret(input: MyAppInput!): GenericSuccessObject!
+ updateOauthApp(input: UpdateAppInput!): GenericSuccessObject!
+ createBotOauthApp(input: CreateBotInput!): PartialUserBase!
+ updateOauthUser(input: UpdateAppUserInput!): OauthUser!
+ addOauthUser(input: AddAppUserInput!): OauthUser!
+ registerBotPrefix(input: RegisterPrefix!): GenericSuccessObject!
+ registerBotCommands(input: RegisterCommands!): GenericSuccessObject!
+ updateEmoji(input: UpdateEmojiInput!): ChatEmoji!
+ deleteEmoji(input: DeleteEmojiInput!): GenericSuccessObject!
+}
+
+input UpdateUserInput {
+ username: String
+ email: String
+ discordPrecache: Boolean
+ darkTheme: Boolean
+ description: String
+ itemsPerPage: Float
+ storedStatus: String
+ weatherUnit: String
+ themeEngine: ThemeEngineInput
+ insights: String
+ profileLayout: ProfileLayoutInput
+ language: String
+ excludedCollections: [Float!]
+ publicProfile: Boolean
+ privacyPolicyAccepted: Boolean
+ nameColor: String
+}
+
+input ThemeEngineInput {
+ theme: ThemeEngineThemesInput!
+ fluidGradient: Boolean!
+ gradientOffset: String!
+ defaults: ThemeEngineThemesInput
+ version: Float!
+ deviceSync: Boolean!
+ showOnProfile: Boolean!
+ baseTheme: String!
+ customCSS: String
+}
+
+input ThemeEngineThemesInput {
+ dark: ThemeEngineThemeInput!
+ light: ThemeEngineThemeInput!
+ amoled: ThemeEngineThemeInput!
+}
+
+input ThemeEngineThemeInput {
+ colors: ThemeEngineColorsInput!
+ dark: Boolean
+}
+
+input ThemeEngineColorsInput {
+ primary: String!
+ logo1: String!
+ logo2: String!
+ secondary: String!
+ accent: String!
+ error: String!
+ info: String!
+ success: String!
+ warning: String!
+ card: String!
+ toolbar: String!
+ sheet: String!
+ text: String!
+ dark: String!
+ gold: String!
+ background: String!
+ background2: String!
+}
+
+input ProfileLayoutInput {
+ layout: ProfileLayoutObjectInput!
+ config: ProfileLayoutConfigInput!
+ version: Float!
+}
+
+input ProfileLayoutObjectInput {
+ columns: [ProfileLayoutColumnInput!]!
+}
+
+input ProfileLayoutColumnInput {
+ rows: [ProfileLayoutComponentInput!]!
+}
+
+input ProfileLayoutComponentInput {
+ name: String!
+ id: String!
+ props: ProfileLayoutPropsInput
+}
+
+input ProfileLayoutPropsInput {
+ height: Float
+ children: [ProfileLayoutComponentInput!]
+ friendsOnly: Boolean
+ display: Float
+ type: String
+ links: [ProfileLayoutPropLinkInput!]
+}
+
+input ProfileLayoutPropLinkInput {
+ name: String!
+ url: String!
+ color: String!
+}
+
+input ProfileLayoutConfigInput {
+ containerMargin: Float
+ showStatsSidebar: Boolean!
+}
+
+type LoginResponse {
+ token: String!
+ user: LoginUser!
+}
+
+type LoginUser {
+ id: Float!
+ username: String!
+ email: String!
+}
+
+input LoginInput {
+ """Username or email"""
+ username: String!
+ password: String!
+
+ """TOTP/2FA code if enabled."""
+ totp: String
+}
+
+input RegisterInput {
+ username: String!
+ password: String!
+ email: String!
+ inviteKey: String
+}
+
+input SetExperimentInput {
+ key: String!
+ value: Int!
+
+ """Admin only."""
+ userId: Int
+}
+
+"""File custom scalar type"""
+scalar File
+
+input CreateChatInput {
+ users: [Float!]!
+ name: String
+}
+
+input UpdateChatInput {
+ name: String
+ associationId: Int!
+
+ """
+ Can only be null or undefined to unset or do not modify the group icon respectively. Use the REST API to set one.
+ """
+ icon: String
+
+ """
+ Can only be null or undefined to unset or do not modify the group background respectively. Use the REST API to set one.
+ """
+ background: String
+ description: String
+}
+
+type GenericSuccessObject {
+ success: Boolean!
+}
+
+"""Used for deleting chats and transferring ownership."""
+input DangerZoneChatInput {
+ associationId: Int!
+
+ """You may use either 2FA token or password to delete the chat."""
+ password: String
+
+ """
+ TOTP/2FA code if enabled. You may use either 2FA token or password to delete the chat.
+ """
+ totp: String
+}
+
+input TransferOwnershipInput {
+ associationId: Int!
+
+ """You may use either 2FA token or password to delete the chat."""
+ password: String
+
+ """
+ TOTP/2FA code if enabled. You may use either 2FA token or password to delete the chat.
+ """
+ totp: String
+
+ """User to transfer to."""
+ userId: Int!
+}
+
+input AddChatUser {
+ chatAssociationId: Int!
+ users: [Float!]!
+ action: ToggleUser!
+}
+
+"""Whether the user should be added, or removed from the group."""
+enum ToggleUser {
+ ADD
+ REMOVE
+}
+
+input AddRank {
+ chatAssociationId: Int!
+ updatingChatAssociationId: Int!
+ rankId: String!
+}
+
+input LeaveChatInput {
+ associationId: Int!
+}
+
+input JoinChatFromInviteInput {
+ inviteId: String!
+}
+
+input AddBotToChatInput {
+ associationId: Float!
+ botAppId: String!
+ permissions: [String!]!
+}
+
+input CreateWorkspaceFolderInput {
+ name: String!
+ workspaceId: Float!
+}
+
+input DeleteWorkspaceItemInput {
+ id: Float!
+ type: WorkspaceItemType!
+}
+
+"""The type of workspace item"""
+enum WorkspaceItemType {
+ NOTE
+ FOLDER
+ WORKSPACE
+}
+
+input SaveNoteInput {
+ id: Float!
+ data: WorkspaceNoteInput!
+ manualSave: Boolean
+ name: String
+}
+
+input WorkspaceNoteInput {
+ version: String
+ blocks: JSON
+ time: Float
+}
+
+input CreateNoteInput {
+ workspaceFolderId: Float!
+ name: String!
+}
+
+input SendMessageInput {
+ content: String!
+ associationId: Float!
+ attachments: [String!]! = []
+ replyId: Float
+ embeds: [EmbedInput!]
+}
+
+input EmbedInput {
+ url: String
+ title: String
+ description: String
+ siteName: String
+ type: String
+ image: String
+ color: String
+ graph: InteractiveGraphInput
+}
+
+input InteractiveGraphInput {
+ type: String!
+}
+
+input UpdateRank {
+ associationId: Int!
+ rankId: String!
+ permissionsMap: [String!]!
+ name: String
+ color: String
+}
+
+input CreateRank {
+ associationId: Int!
+ name: String
+ color: String
+}
+
+input UpdateRankOrder {
+ associationId: Int!
+
+ """
+ Order if the rank, this is actually reversed from expected index value, so rankIds[0] is the highest priority rank.
+ """
+ rankIds: [String!]!
+}
+
+input DeleteRank {
+ associationId: Int!
+ rankId: String!
+}
+
+input BlockUserInput {
+ userId: Int!
+ silent: Boolean!
+}
+
+input CreateInviteInput {
+ """In hours."""
+ expiry: Int
+
+ """Auto assign rank on join."""
+ rankId: String
+ associationId: Int!
+}
+
+type AuthorizeAppResponse {
+ token: String
+}
+
+input AuthorizeAppInput {
+ id: String!
+ scopes: String!
+
+ """Used for bots."""
+ permissions: [String!]
+}
+
+input CreateAppInput {
+ name: String!
+ description: String
+ redirectUri: String
+ private: Boolean!
+ verified: Boolean!
+}
+
+input UpdateAppInput {
+ name: String!
+ description: String
+ redirectUri: String
+ private: Boolean!
+ verified: Boolean!
+ id: String!
+}
+
+input CreateBotInput {
+ id: String!
+ username: String!
+}
+
+input UpdateAppUserInput {
+ id: String!
+ oauthAppId: String!
+ manage: Boolean!
+}
+
+input AddAppUserInput {
+ username: String!
+ oauthAppId: String!
+ manage: Boolean
+}
+
+input RegisterPrefix {
+ prefix: String!
+}
+
+input RegisterCommands {
+ commands: [RegisterCommand!]!
+}
+
+input RegisterCommand {
+ command: String!
+ description: String!
+}
+
+input UpdateEmojiInput {
+ id: String!
+ name: String!
+ associationId: Float!
+}
+
+input DeleteEmojiInput {
+ id: String!
+ associationId: Float!
+}
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
index 909a081..9de7b6a 100644
Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/troplo/privateuploader/App.kt b/app/src/main/java/com/troplo/privateuploader/App.kt
index da30cfc..53ce2be 100644
--- a/app/src/main/java/com/troplo/privateuploader/App.kt
+++ b/app/src/main/java/com/troplo/privateuploader/App.kt
@@ -2,8 +2,8 @@ package com.troplo.privateuploader
import android.app.Application
import androidx.startup.AppInitializer
-import io.wax911.emojify.EmojiManager
-import io.wax911.emojify.initializer.EmojiInitializer
+//import io.wax911.emojify.EmojiManager
+//import io.wax911.emojify.initializer.EmojiInitializer
class TpuApp : Application() {
diff --git a/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt b/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt
index 274791b..16135ec 100644
--- a/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt
+++ b/app/src/main/java/com/troplo/privateuploader/FirebaseChatService.kt
@@ -32,6 +32,7 @@ import coil.request.ImageRequest
import coil.size.Size
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
+import com.troplo.privateuploader.api.SessionManager
import com.troplo.privateuploader.api.TpuApi
import com.troplo.privateuploader.api.TpuFunctions
import com.troplo.privateuploader.api.imageLoader
@@ -60,7 +61,7 @@ class FirebaseChatService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, "[NewChatService] Message received")
- if (isAppOnForeground(this)) {
+ if (isAppOnForeground(this) && remoteMessage.data["type"] == "message") {
Log.d(TAG, "[NewChatService] App is on foreground")
return
}
@@ -73,7 +74,8 @@ class FirebaseChatService : FirebaseMessagingService() {
chatName = remoteMessage.data["chatName"] ?: "",
associationId = remoteMessage.data["associationId"]?.toInt() ?: 0,
avatar = remoteMessage.data["avatar"] ?: "",
- id = remoteMessage.data["id"]?.toInt() ?: 0
+ id = remoteMessage.data["id"]?.toInt() ?: 0,
+ type = remoteMessage.data["type"] ?: ""
)
)
}
@@ -140,95 +142,121 @@ class FirebaseChatService : FirebaseMessagingService() {
// for ActivityCompat#requestPermissions for more details.
return
}
- asyncLoadIcon(message.avatar, this) {
- try {
- Log.d("TPU.Untagged", "[ChatService] Loaded icon")
- val chatPartner = Person.Builder().apply {
- setName(message.username)
- setKey(message.userId.toString())
- setIcon(it)
- setImportant(false)
- }.build()
-
- val notificationManager = NotificationManagerCompat.from(this)
- val channel = NotificationChannel(
- "communications",
- "Messages from Communications",
- NotificationManager.IMPORTANCE_HIGH
- )
- notificationManager.createNotificationChannel(channel)
- if (messages[message.associationId] == null) messages[message.associationId] =
- mutableListOf()
- messages[message.associationId]?.add(
- NotificationCompat.MessagingStyle.Message(
- message.content,
- TpuFunctions.getDate(message.createdAt)?.time ?: 0,
- chatPartner
+
+ if(message.type == "message") {
+ asyncLoadIcon(message.avatar, this) {
+ try {
+ Log.d("TPU.Untagged", "[ChatService] Loaded icon")
+ val chatPartner = Person.Builder().apply {
+ setName(message.username)
+ setKey(message.userId.toString())
+ setIcon(it)
+ setImportant(false)
+ }.build()
+
+ val notificationManager = NotificationManagerCompat.from(this)
+ val channel = NotificationChannel(
+ "communications",
+ "Messages from Communications",
+ NotificationManager.IMPORTANCE_HIGH
)
- )
+ notificationManager.createNotificationChannel(channel)
+ if (messages[message.associationId] == null) messages[message.associationId] =
+ mutableListOf()
- val style = NotificationCompat.MessagingStyle(chatPartner)
- .setConversationTitle(message.chatName)
- for (msg in messages[message.associationId]!!) {
- style.addMessage(msg)
- }
+ Log.d("TPU.Firebase", "[ChatService] Secure message")
+ val rep = Intent(this, InlineNotificationActivity::class.java)
+ rep.replaceExtras(Bundle())
+ rep.putExtra("chatId", message.associationId)
+ Log.d("TPU.Firebase", "[ChatService] ${rep.extras}")
+ val style = NotificationCompat.MessagingStyle(chatPartner)
+ .setConversationTitle(message.chatName)
+ val replyPendingIntent = PendingIntent.getBroadcast(
+ this@FirebaseChatService,
+ message.associationId,
+ rep,
+ PendingIntent.FLAG_MUTABLE
+ )
+
+ val remoteInput = RemoteInput.Builder("content")
+ .setLabel("Reply")
+ .build()
+
+ val replyAction = NotificationCompat.Action.Builder(
+ R.drawable.flowinity_logo,
+ "Reply",
+ replyPendingIntent
+ )
+ .addRemoteInput(remoteInput)
+ .setAllowGeneratedReplies(true)
+ .build()
+ Log.d("TPU.Firebase", "[ChatService] ${message.associationId}")
+ val builder: NotificationCompat.Builder =
+ NotificationCompat.Builder(this, "communications")
+ .addPerson(chatPartner)
+ .setContentText(message.content)
+ .setContentTitle(message.username)
+ .setSmallIcon(R.drawable.flowinity_logo)
+ .setWhen(TpuFunctions.getDate(message.createdAt)?.time ?: 0)
+ .addAction(replyAction)
+ .setContentIntent(
+ PendingIntent.getActivity(
+ this,
+ message.associationId,
+ Intent(this, MainActivity::class.java).apply {
+ putExtra("chatId", message.associationId)
+ },
+ PendingIntent.FLAG_MUTABLE
+ )
+ )
+ CoroutineScope(Dispatchers.IO).launch {
+ TpuApi.init(SessionManager(this@FirebaseChatService).getAuthToken() ?: "", this@FirebaseChatService)
+ val messageRequest = TpuApi.retrofitService.getMessage(
+ messageId = message.id
+ ).execute()
- val rep = Intent(this, InlineNotificationActivity::class.java)
- rep.replaceExtras(Bundle())
- rep.putExtra("chatId", message.associationId)
- val replyPendingIntent = PendingIntent.getBroadcast(
- this,
- message.associationId,
- rep,
- PendingIntent.FLAG_MUTABLE
- )
-
- val remoteInput = RemoteInput.Builder("content")
- .setLabel("Reply")
- .build()
-
- val replyAction = NotificationCompat.Action.Builder(
- R.drawable.tpu_logo,
- "Reply",
- replyPendingIntent
- )
- .addRemoteInput(remoteInput)
- .setAllowGeneratedReplies(true)
- .build()
-
- val builder: NotificationCompat.Builder =
- NotificationCompat.Builder(this, "communications")
- .addPerson(chatPartner)
- .setStyle(style)
- .setContentText(message.content)
- .setContentTitle(message.username)
- .setSmallIcon(R.drawable.tpu_logo)
- .setWhen(TpuFunctions.getDate(message.createdAt)?.time ?: 0)
- .addAction(replyAction)
- .setContentIntent(
- PendingIntent.getActivity(
- this,
- message.associationId,
- Intent(this, MainActivity::class.java).apply {
- putExtra("chatId", message.associationId)
- },
- PendingIntent.FLAG_MUTABLE
+ if (messageRequest.isSuccessful) {
+ Log.d(
+ "TPU.Firebase",
+ "New message came through, ${messageRequest.body()?.content}"
)
- )
- val res = notificationManager.notify(message.associationId, builder.build())
- Log.d("TPU.Untagged", "[ChatService] Notification sent, $res")
- } catch (e: Exception) {
- Log.d(
- "TPU.Untagged",
- "[ChatService] Error sending notification, ${e.printStackTrace()}"
- )
+ messages[message.associationId]?.add(
+ NotificationCompat.MessagingStyle.Message(
+ messageRequest.body()?.content ?: "",
+ TpuFunctions.getDate(message.createdAt)?.time ?: 0,
+ chatPartner
+ )
+ )
+ }
+
+ Log.d("TPU.Firebase", "[ChatService] Added message to list")
+
+ for (msg in messages[message.associationId]!!) {
+ style.addMessage(msg)
+ }
+
+ builder.setStyle(style)
+
+ val res = notificationManager.notify(message.associationId, builder.build())
+ Log.d("TPU.Untagged", "[ChatService] Notification sent, $res")
+ }
+ } catch (e: Exception) {
+ Log.d(
+ "TPU.Untagged",
+ "[ChatService] Error sending notification, ${e.printStackTrace()}"
+ )
+ }
}
+ } else if(message.type == "read") {
+ val notificationManager = NotificationManagerCompat.from(this)
+ notificationManager.cancel(message.associationId)
}
}
companion object {
private const val TAG = "FirebaseChatService"
+ private const val FAKE_MESSAGE_CONTENT = "Please update your version of the Flowinity app."
}
internal class MyWorker(appContext: Context, workerParams: WorkerParameters) :
diff --git a/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt b/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt
index 25c8a94..34782ab 100644
--- a/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt
+++ b/app/src/main/java/com/troplo/privateuploader/InlineNotificationActivity.kt
@@ -18,23 +18,24 @@ class InlineNotificationActivity : BroadcastReceiver() {
try {
Log.d(
"TPU.Untagged",
- "[ChatService] InlineNotificationActivity onCreate, intent: $intent, extras: ${intent.extras}"
+ "[Firebase] InlineNotificationActivity onCreate, intent: $intent, extras: ${intent.extras}"
)
val chatId = intent.getIntExtra("chatId", 0)
val remoteInput = RemoteInput.getResultsFromIntent(intent)
val content = remoteInput?.getCharSequence("content")?.toString()
+ Log.d("InlineNotificationAct", "Firebase - chatId: $chatId, content: $content")
TpuApi.init(SessionManager(context).getAuthToken() ?: "", context)
sendReply(chatId, content, context)
} catch (e: Exception) {
- Log.d("TPU.InlineNotificationActivity", "Exception: $e")
+ Log.d("TPU.InlineNotificationAct", "Firebase - Exception: $e")
}
}
private fun sendReply(chatId: Int, content: String?, context: Context) {
try {
if (chatId == 0) return
- Log.d("TPU.Untagged", "Sending reply to chatId: $chatId")
+ Log.d("TPU.Untagged", "Firebase - Sending reply to chatId: $chatId")
CoroutineScope(Dispatchers.IO).launch {
val response = TpuApi.retrofitService.sendMessage(
id = chatId, messageRequest = MessageRequest(
@@ -43,7 +44,7 @@ class InlineNotificationActivity : BroadcastReceiver() {
).execute()
}
} catch (e: Exception) {
- Log.d("TPU.InlineNotificationActivity", "sendReply exception: $e")
+ Log.d("TPU.InlineNotificationAct", "Firebase - sendReply exception: $e")
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/MainActivity.kt b/app/src/main/java/com/troplo/privateuploader/MainActivity.kt
index 7fe6596..6313f67 100644
--- a/app/src/main/java/com/troplo/privateuploader/MainActivity.kt
+++ b/app/src/main/java/com/troplo/privateuploader/MainActivity.kt
@@ -1,6 +1,7 @@
package com.troplo.privateuploader
import android.Manifest
+import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
@@ -11,6 +12,8 @@ import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.toMutableStateList
import androidx.core.content.ContextCompat
import androidx.startup.AppInitializer
import com.google.android.gms.common.GoogleApiAvailability
@@ -20,13 +23,15 @@ import com.troplo.privateuploader.api.SocketHandler
import com.troplo.privateuploader.api.SocketHandlerService
import com.troplo.privateuploader.api.TpuApi
import com.troplo.privateuploader.api.TpuFunctions
+import com.troplo.privateuploader.api.stores.AppStore
import com.troplo.privateuploader.api.stores.CollectionStore
+import com.troplo.privateuploader.api.stores.CoreStore
import com.troplo.privateuploader.api.stores.UploadStore
import com.troplo.privateuploader.api.stores.UserStore
import com.troplo.privateuploader.data.model.UploadTarget
import com.troplo.privateuploader.ui.theme.PrivateUploaderTheme
-import io.wax911.emojify.EmojiManager
-import io.wax911.emojify.initializer.EmojiInitializer
+//import io.wax911.emojify.EmojiManager
+//import io.wax911.emojify.initializer.EmojiInitializer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -38,8 +43,10 @@ import okhttp3.RequestBody.Companion.asRequestBody
class MainActivity : ComponentActivity() {
+
override fun onResume() {
super.onResume()
+ AppStore.foreground = true
GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(this)
val socket = SocketHandler.getSocket()
if (socket != null && !socket.connected()) {
@@ -47,8 +54,14 @@ class MainActivity : ComponentActivity() {
}
}
+ override fun onPause() {
+ super.onPause()
+ AppStore.foreground = false
+ }
+
override fun onStart() {
super.onStart()
+ AppStore.foreground = true
val socket = SocketHandler.getSocket()
if (socket != null && !socket.connected()) {
socket.connect()
@@ -69,6 +82,7 @@ class MainActivity : ComponentActivity() {
SocketHandler.initializeSocket(token, this)
UserStore.initializeUser(this)
}
+ CoreStore.initializeCore()
if (token != null) {
setContent {
PrivateUploaderTheme(
@@ -149,11 +163,20 @@ class MainActivity : ComponentActivity() {
}
}
- fun upload(files: List) {
- UploadStore.uploads = files.toMutableList()
+ public fun upload(files: List, deleteOnceFinished: Boolean = true, context: Context = this): List? {
+ var uploads = mutableListOf()
+ Log.d("TPU.Upload", "Uploading ${files.size} files")
+
+ if(!files.any()) {
+ return null
+ }
+
+ if(deleteOnceFinished) {
+ UploadStore.uploads = files.toMutableStateList()
+ }
val filesBody = files.map { file ->
- TpuFunctions.uriToFile(file.uri, this, file.name)
+ TpuFunctions.uriToFile(file.uri, context, file.name)
}
CoroutineScope(Dispatchers.IO).launch {
@@ -185,16 +208,34 @@ class MainActivity : ComponentActivity() {
}
val response = TpuApi.retrofitService.uploadFiles(parts).execute()
- response.body()?.let {
+ response.body()?.let { upload ->
UploadStore.globalProgress.value = 0f
- UploadStore.uploads = mutableListOf()
+ if(deleteOnceFinished) {
+ UploadStore.uploads.clear()
+ } else {
+ // set the upload status to finished
+ UploadStore.uploads.find { it.uri == files.first().uri }?.let {
+ it.progress = 100f
+ it.url = upload[0].upload.attachment
+ }
+ Log.d("TPU.Upload", "Upload finished: ${upload[0].upload.attachment}, ${UploadStore.uploads.toList().toString()}")
+ }
+
+ uploads = upload.map { it.upload.attachment }.toMutableList()
+ }
+
+ return@launch withContext(Dispatchers.Main) {
+ uploads
}
}
+
+ return null
}
- @Deprecated("Deprecated in Java")
+ @Deprecated("Deprecated in Java :(")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
+ Log.d("TPU.Upload", "Upload response received (MainActivity)")
if (requestCode == UploadStore.intentCode && resultCode == RESULT_OK) {
if (data != null) {
if (data.clipData != null) {
@@ -247,11 +288,11 @@ class MainActivity : ComponentActivity() {
}
- internal val emojiManager: EmojiManager by lazy {
- // should already be initialized if we haven't disabled initialization in manifest
- // see: https://developer.android.com/topic/libraries/app-startup#disable-individual
- AppInitializer.getInstance(this)
- .initializeComponent(EmojiInitializer::class.java)
- }
+// internal val emojiManager: EmojiManager by lazy {
+// should already be initialized if we haven't disabled initialization in manifest
+// see: https://developer.android.com/topic/libraries/app-startup#disable-individual
+// AppInitializer.getInstance(this)
+// .initializeComponent(EmojiInitializer::class.java)
+// }
}
diff --git a/app/src/main/java/com/troplo/privateuploader/MainScreen.kt b/app/src/main/java/com/troplo/privateuploader/MainScreen.kt
index 52bd6fb..3161ce8 100644
--- a/app/src/main/java/com/troplo/privateuploader/MainScreen.kt
+++ b/app/src/main/java/com/troplo/privateuploader/MainScreen.kt
@@ -4,8 +4,10 @@ import android.util.Log
import androidx.activity.OnBackPressedCallback
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -13,9 +15,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
-import androidx.compose.material.LinearProgressIndicator
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.DrawerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
@@ -136,19 +139,20 @@ fun MainScreen() {
}
) { paddingValues ->
OverlappingPanels(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.background(MaterialTheme.colorScheme.surface).fillMaxSize(),
panelsState = panelState,
gesturesEnabled = navController.currentDestination?.route?.startsWith("chat/") == true || !panelState.isPanelsClosed,
panelStart = {
PanelSurface {
ModalDrawerSheet(
modifier = Modifier.padding(
- top = paddingValues.calculateTopPadding(),
- bottom = paddingValues.calculateBottomPadding()
- ),
+ // hack for a16: originally calculateTopPadding but value too high
+ top = 64.dp,
+ bottom = 80.dp
+ )
+ .background(MaterialTheme.colorScheme.surfaceContainer),
drawerTonalElevation = if(isAMOLED.value) 0.dp else DrawerDefaults.ModalDrawerElevation
) {
- Spacer(Modifier.height(12.dp))
HomeScreen(
openChat = { chatId ->
if(ChatStore.associationId.value != chatId) {
@@ -167,7 +171,8 @@ fun MainScreen() {
PanelSurface {
NavGraph(
modifier = Modifier.padding(
- top = paddingValues.calculateTopPadding(),
+ // hack for a16: originally calculateTopPadding but value too high
+ top = 64.dp,
bottom = if (navController.currentDestination?.route?.startsWith("chat/") == true) 0.dp else paddingValues.calculateBottomPadding()
),
navController = navController,
@@ -180,17 +185,20 @@ fun MainScreen() {
panelEnd = {
PanelSurface {
ModalDrawerSheet(
- modifier = Modifier.padding(
- top = paddingValues.calculateTopPadding(),
- bottom = paddingValues.calculateBottomPadding()
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(
+ // hack for a16: originally calculateTopPadding but value too high
+ top = 64.dp,
+ bottom = 0.dp
),
drawerTonalElevation = if(isAMOLED.value) 0.dp else DrawerDefaults.ModalDrawerElevation
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
- .weight(weight = 1f, fill = false)
-
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .weight(weight = 1f, fill = true)
) {
Spacer(Modifier.height(12.dp))
MemberSidebar()
diff --git a/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt b/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt
index f2d35a5..81fc69b 100644
--- a/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt
+++ b/app/src/main/java/com/troplo/privateuploader/UploadResponseActivity.kt
@@ -18,6 +18,7 @@ class UploadResponseActivity: Activity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == UploadStore.intentCode && resultCode == RESULT_OK) {
+ Log.d("TPU.UploadResponse", "Upload response received")
if (data != null) {
if (data.clipData != null) {
// Multiple files were selected
diff --git a/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt b/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt
index 33e778e..5845cc1 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/ApiService.kt
@@ -10,6 +10,7 @@ import com.troplo.privateuploader.data.model.Chat
import com.troplo.privateuploader.data.model.ChatCreateRequest
import com.troplo.privateuploader.data.model.Collection
import com.troplo.privateuploader.data.model.CollectionItem
+import com.troplo.privateuploader.data.model.Collections
import com.troplo.privateuploader.data.model.CollectivizeRequest
import com.troplo.privateuploader.data.model.CreateCollectionRequest
import com.troplo.privateuploader.data.model.EditRequest
@@ -317,7 +318,7 @@ object TpuApi {
): Call
@GET("collections")
- fun getCollections(): Call>
+ fun getCollections(): Call
@POST("collections/attachment")
fun collectivize(
@@ -388,6 +389,11 @@ object TpuApi {
@Path("associationId") associationId: Int,
@Body pinRequest: PinRequest
): Call
+
+ @GET("chats/messages/{messageId}")
+ fun getMessage(
+ @Path("messageId") messageId: Int
+ ): Call
}
val retrofitService: TpuApiService by lazy {
diff --git a/app/src/main/java/com/troplo/privateuploader/api/Functions.kt b/app/src/main/java/com/troplo/privateuploader/api/Functions.kt
index 9dfa9ee..e1cbab7 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/Functions.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/Functions.kt
@@ -3,6 +3,7 @@ package com.troplo.privateuploader.api
import android.content.Context
import android.net.Uri
import android.util.Log
+import com.troplo.privateuploader.api.stores.CoreStore
import com.troplo.privateuploader.api.stores.FriendStore
import com.troplo.privateuploader.data.model.Chat
import com.troplo.privateuploader.data.model.PartialUser
@@ -22,9 +23,15 @@ import kotlin.math.ln
import kotlin.math.pow
object TpuFunctions {
- fun image(link: String?, recipient: User?): String? {
+ fun image(link: String?, recipient: User?, width: Int? = null, height: Int? = null): String? {
+ val coreStore = CoreStore.core.value
+ val domain = "https://${coreStore?.domain ?: "i.troplo.com"}"
if (recipient?.avatar != null) {
- return "https://i.troplo.com/i/${recipient.avatar}"
+ var string = "$domain/i/${recipient.avatar}"
+ if (width != null && height != null) {
+ string += "?width=$width&height=$height"
+ }
+ return string
}
if (link == null) {
return null
@@ -32,7 +39,11 @@ object TpuFunctions {
return if (link.length >= 20) {
"https://colubrina.troplo.com/usercontent/$link"
} else {
- "https://i.troplo.com/i/$link"
+ var string = "$domain/i/$link"
+ if (width != null && height != null) {
+ string += "?width=$width&height=$height"
+ }
+ return string
}
}
@@ -108,9 +119,9 @@ object TpuFunctions {
return ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
}
- fun fileSize(size: Int?): String {
- if (size == null || size == 0) return "0B"
- val unit = 1024
+ fun fileSize(size: Long?): String {
+ if (size == null || size == 0L) return "0B"
+ val unit = 1024L
if (size < unit) return "${size}B"
val exp = (ln(size.toDouble()) / ln(unit.toDouble())).toInt()
val pre = "KMGTPE"[exp - 1] + "i"
diff --git a/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt b/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt
index c176075..8254efb 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/ImageLoader.kt
@@ -7,7 +7,9 @@ import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.newFixedThreadPoolContext
+val dispatcher = newFixedThreadPoolContext(2, "CoilDispatcherTPU")
fun imageLoader(context: Context, cache: Boolean? = true): ImageLoader {
return ImageLoader.Builder(context)
.components {
@@ -17,7 +19,7 @@ fun imageLoader(context: Context, cache: Boolean? = true): ImageLoader {
add(GifDecoder.Factory())
}
}
- .dispatcher(Dispatchers.IO)
+ .dispatcher(dispatcher)
.diskCache {
if (cache == true) {
DiskCache.Builder()
diff --git a/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt b/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt
index df3ffba..bf79510 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/SocketHandler.kt
@@ -9,19 +9,28 @@ import com.troplo.privateuploader.BuildConfig
import com.troplo.privateuploader.data.model.MessageEvent
import com.troplo.privateuploader.data.model.Typing
import io.socket.client.IO
+import io.socket.client.Manager
import io.socket.client.Socket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
+import java.net.URI
import java.net.URISyntaxException
import java.util.Collections
import java.util.concurrent.Executors
+
object SocketHandler {
var baseUrl = BuildConfig.SERVER_URL
- private var socket: Socket? = null
+ private var chatSocket: Socket? = null
+ private var gallerySocket: Socket? = null
+ private var userSocket: Socket? = null
+ private var friendsSocket: Socket? = null
+ private var trackedUsersSocket: Socket? = null
+
+ private var manager: Manager? = null
val gson = Gson()
var connected = mutableStateOf(false)
@@ -30,63 +39,88 @@ object SocketHandler {
val options = IO.Options()
options.forceNew = true
options.reconnection = true
+ Log.d("TPU.SocketToken", token)
options.auth = Collections.singletonMap("token", token)
- options.query = "platform=$platform&version=3"
+ options.query = "platform=$platform&version=4"
options.transports = arrayOf("websocket")
options.reconnectionDelay = 1000
options.reconnectionDelayMax = 5000
- options.reconnectionAttempts = 99999
- socket = IO.socket(baseUrl, options)
- if (socket != null) {
- socket?.open()
+ options.reconnectionAttempts = 9999
+ options.path = "/gateway"
+ manager = Manager(URI(baseUrl), options)
+ chatSocket = manager!!.socket("/chat", options)
+ gallerySocket = manager!!.socket("/gallery", options)
+ userSocket = manager!!.socket("/user", options)
+ friendsSocket = manager!!.socket("/friends", options)
+ trackedUsersSocket = manager!!.socket("/trackedUsers", options)
+
+ gallerySocket?.open()
+ userSocket?.open()
+ friendsSocket?.open()
+ trackedUsersSocket?.open()
+
+ if (chatSocket != null) {
+ chatSocket?.open()
if (platform !== "android_kotlin_background_service") {
- socket?.on(Socket.EVENT_CONNECT) {
+ chatSocket?.on(Socket.EVENT_CONNECT) {
this.connected.value = true
Log.d(
"TPU.Untagged",
- "Socket connected ${socket?.isActive}, Connected: ${this.connected.value}"
+ "Socket connected ${chatSocket?.isActive}, Connected: ${this.connected.value}"
)
}
- socket?.on(Socket.EVENT_DISCONNECT) {
+ chatSocket?.on(Socket.EVENT_DISCONNECT) {
this.connected.value = false
Log.d(
"TPU.Untagged",
- "Socket disconnected ${socket?.isActive}, Connected: ${this.connected.value}"
+ "Socket disconnected ${chatSocket?.isActive}, Connected: ${this.connected.value}, Error: ${it[0]}"
)
}
- socket?.on(Socket.EVENT_CONNECT_ERROR) {
+ chatSocket?.on(Socket.EVENT_CONNECT_ERROR) {
try {
this.connected.value = false
Log.d(
"TPU.Untagged",
- "Socket connect error ${socket?.isActive}, Connected: ${this.connected.value}, Error: ${it[0]}"
+ "Socket connect error ${chatSocket?.isActive}, Connected: ${this.connected.value}, Error: ${it[0]}, URL: ${baseUrl}/gateway"
)
} catch (e: Exception) {
//
}
}
- socket?.on("message") { it ->
+ chatSocket?.on("message") { it ->
val jsonArray = it[0] as JSONObject
val payload = jsonArray.toString()
val messageEvent = gson.fromJson(payload, MessageEvent::class.java)
- val message = messageEvent.message
+ // Change in v4 uses uppercase type
+ val message = messageEvent.message.copy(
+ type = messageEvent.message.type?.lowercase()
+ )
+
Log.d("TPU.Untagged", "Message received (SocketHandler): $message")
- if (messageEvent.association.id != ChatStore.associationId.value) {
+ val chat =
+ ChatStore.chats.find { it.association?.id?.toInt() == messageEvent.associationId.toInt() }
+ val unread = chat?.unread ?: 0
+ Log.d("MessageStore", "${messageEvent.associationId}, ${ChatStore.associationId.value}")
+ Log.d("MessageStore", "Chat: $chat")
+ if (messageEvent.associationId != ChatStore.associationId.value) {
// increase unread count
- val chat =
- ChatStore.chats.value.find { it.association?.id == messageEvent.association.id }
- Log.d("TPU.Untagged", chat.toString())
- if (chat != null) {
- chat.unread = chat.unread?.plus(1)
- ChatStore.setChats(listOf(chat) + ChatStore.chats.value.filter { it.association?.id != messageEvent.association.id })
+ Log.d("MessageStore", "Increasing unread count: ${chat?.unread?.plus(1)}")
+ unread.plus(1)
+ }
+ Log.d("MessageStore", "Unread; $unread, Chat: $chat")
+ if(chat != null) {
+ val modifiedChats = ChatStore.chats.toMutableList()
+ val index = modifiedChats.indexOfFirst { it.id == chat.id }
+ if (index != -1) {
+ modifiedChats.removeAt(index)
+ modifiedChats.add(0, chat.copy(unread = unread))
+ ChatStore.chats.clear()
+ ChatStore.chats.addAll(modifiedChats)
}
}
-
- // if running in background, send notification
-
}
- socket?.on("typing") { it ->
+ chatSocket?.on("typing") { it ->
CoroutineScope(Dispatchers.IO).launch {
val jsonArray = it[0] as JSONObject
val payload = jsonArray.toString()
@@ -111,7 +145,7 @@ object SocketHandler {
}
}
}
- Log.d("TPU.Untagged", "Socket connected ${socket?.isActive}")
+ Log.d("TPU.Untagged", "Socket connected ${chatSocket?.isActive}")
} else {
Log.d("TPU.Untagged", "Socket is null")
}
@@ -129,11 +163,29 @@ object SocketHandler {
fun getSocket(): Socket? {
- return socket
+ return chatSocket
+ }
+
+ fun getGallerySocket(): Socket? {
+ return gallerySocket
+ }
+
+ fun getUserSocket(): Socket? {
+ return userSocket
+ }
+
+ fun getFriendsSocket(): Socket? {
+ return friendsSocket
+ }
+
+ fun getTrackedUsersSocket(): Socket? {
+ return trackedUsersSocket
}
fun closeSocket() {
- socket?.disconnect()
- socket = null
+ chatSocket?.disconnect()
+ chatSocket = null
+ gallerySocket?.disconnect()
+ gallerySocket = null
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt b/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt
index 7e85f09..7807907 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/SocketHandlerService.kt
@@ -22,12 +22,12 @@ object SocketHandlerService {
options.forceNew = true
options.reconnection = true
options.auth = Collections.singletonMap("token", token)
- options.query = "platform=$platform&version=3"
+ options.query = "platform=$platform&version=4"
options.transports = arrayOf("websocket")
options.reconnectionDelay = 1000
options.reconnectionDelayMax = 5000
options.reconnectionAttempts = 99999
- socket = IO.socket(baseUrl, options)
+ socket = IO.socket("$baseUrl/gateway", options)
if (socket != null) {
socket?.open()
Log.d("TPU.Untagged", "Socket connected ${socket?.isActive}")
diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt
index 5927c44..47b712f 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/stores/AppStore.kt
@@ -5,4 +5,5 @@ import androidx.navigation.NavController
object AppStore {
var navController: NavController? = null
+ var foreground = false
}
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt
index 61d4dd1..c91328a 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/stores/ChatStore.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
+import com.troplo.privateuploader.api.stores.AppStore
import com.troplo.privateuploader.data.model.AddChatUsersEvent
import com.troplo.privateuploader.data.model.Chat
import com.troplo.privateuploader.data.model.PinRequest
@@ -22,24 +23,19 @@ import java.net.URISyntaxException
object ChatStore {
- private val _chats: MutableStateFlow> = MutableStateFlow(emptyList())
+ val chats = mutableStateListOf()
var associationId = MutableStateFlow(0)
var typers = MutableStateFlow(emptyList())
var jumpToMessage = MutableStateFlow(0)
var hasInit = false
val searchPanel = MutableStateFlow(false)
- // To upload to TPU, uses URI Android system
- var attachmentsToUpload = mutableStateListOf()
-
- val chats: StateFlow>
- get() = _chats
-
fun initializeChats() {
try {
CoroutineScope(Dispatchers.IO).launch {
val response = TpuApi.retrofitService.getChats().execute().body() ?: emptyList()
- _chats.value = response
+ chats.clear()
+ chats.addAll(response)
}
if(hasInit) return
hasInit = true
@@ -50,30 +46,27 @@ object ChatStore {
val payload = jsonArray.toString()
val chat = SocketHandler.gson.fromJson(payload, Chat::class.java)
// add it to the top
- _chats.value = listOf(chat).plus(_chats.value)
+ chats.add(0, chat)
}
socket.on("removeChat") {
val jsonArray = it[0] as JSONObject
val payload = jsonArray.toString()
val chat = SocketHandler.gson.fromJson(payload, RemoveChatEvent::class.java)
- _chats.value = _chats.value.filter { it.id != chat.id }
+ val index = chats.indexOfFirst { it.id == chat.id }
+ chats.removeAt(index)
}
socket.on("removeChatUser") {
val jsonArray = it[0] as JSONObject
val payload = jsonArray.toString()
val assoc = SocketHandler.gson.fromJson(payload, RemoveChatUserEvent::class.java)
- val chatIndex = _chats.value.indexOfFirst { it.id == assoc.chatId }
+ val chatIndex = chats.indexOfFirst { it.id == assoc.chatId }
if (chatIndex != -1) {
- val chat = _chats.value[chatIndex]
+ val chat = chats[chatIndex]
val userIndex = chat.users.indexOfFirst { it.id == assoc.id }
if (userIndex != -1) {
- _chats.value = _chats.value.toMutableList().apply {
- this[chatIndex] = chat.copy(users = chat.users.toMutableList().apply {
- this.removeAt(userIndex)
- })
- }
+ chats[chatIndex].users.toMutableList().removeAt(userIndex)
}
}
}
@@ -83,14 +76,12 @@ object ChatStore {
val payload = jsonArray.toString()
val users = SocketHandler.gson.fromJson(payload, AddChatUsersEvent::class.java)
- val chatIndex = _chats.value.indexOfFirst { it.id == users.chatId }
+ val chatIndex = chats.indexOfFirst { it.id == users.chatId }
if (chatIndex != -1) {
- val chat = _chats.value[chatIndex]
- _chats.value = _chats.value.toMutableList().apply {
- this[chatIndex] = chat.copy(users = chat.users.toMutableList().apply {
- this.addAll(users.users)
- })
- }
+ val chat = chats[chatIndex]
+ chats[chatIndex] = chats[chatIndex].copy(users = chats[chatIndex].users.toMutableList().apply {
+ addAll(users.users)
+ })
}
}
} else {
@@ -102,7 +93,7 @@ object ChatStore {
}
fun getChat(): Chat? {
- return chats.value.find { it.association?.id == associationId.value }
+ return chats.find { it.association?.id == associationId.value }
}
fun setAssociationId(id: Int, context: Context) {
@@ -110,18 +101,19 @@ object ChatStore {
associationId.value = id
// Handle unread count, and init read receipt
+ // Ensure that the app is in the foreground
+
val socket = SocketHandler.getSocket()
- socket?.emit("readChat", id)
- val chat = chats.value.find { it.association?.id == id }
- if (chat != null) {
- chat.unread = 0
+ Log.d("MarkAsRead", "Foreground: ${AppStore.foreground}")
+ if(AppStore.foreground) {
+ socket?.emit("readChat", id)
+ val chat = chats.find { it.association?.id == id }
+ if (chat != null) {
+ chat.unread = 0
+ }
}
}
- fun setChats(chats: List) {
- _chats.value = chats
- }
-
fun deleteMessage(messageId: Int) {
if (messageId == 0 || associationId.value == 0) return
diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt
index 92ce6b5..110d37b 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/stores/CollectionStore.kt
@@ -2,6 +2,8 @@ package com.troplo.privateuploader.api.stores
import com.troplo.privateuploader.api.TpuApi
import com.troplo.privateuploader.data.model.Collection
+import com.troplo.privateuploader.data.model.Collections
+import com.troplo.privateuploader.data.model.Pager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -10,7 +12,7 @@ import java.net.URISyntaxException
object CollectionStore {
- var collections: MutableStateFlow> = MutableStateFlow(listOf())
+ var collections: MutableStateFlow = MutableStateFlow(Collections(listOf(), Pager(0, 0, 0, 0, listOf(), 0, 0, 0, 0)))
fun initializeCollections() {
try {
diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/CoreStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/CoreStore.kt
new file mode 100644
index 0000000..3451cf1
--- /dev/null
+++ b/app/src/main/java/com/troplo/privateuploader/api/stores/CoreStore.kt
@@ -0,0 +1,35 @@
+package com.troplo.privateuploader.api.stores
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.NavController
+import com.troplo.privateuploader.api.SessionManager
+import com.troplo.privateuploader.api.SocketHandler
+import com.troplo.privateuploader.api.SocketHandlerService
+import com.troplo.privateuploader.api.TpuApi
+import com.troplo.privateuploader.data.model.Friend
+import com.troplo.privateuploader.data.model.State
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+
+object CoreStore {
+ var core: MutableState = mutableStateOf(null)
+
+ fun initializeCore() {
+ CoroutineScope(
+ Dispatchers.IO
+ ).launch {
+ val data = TpuApi.retrofitService.getInstanceInfo().execute()
+ launch(Dispatchers.Main) {
+ if (data.isSuccessful) {
+ val body = data.body()!!
+ core.value = body
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt
index 477e59d..bc280f2 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/stores/FriendStore.kt
@@ -19,8 +19,9 @@ object FriendStore {
fun initializeFriends() {
try {
- val socket = SocketHandler.getSocket()
- socket?.off("userStatus")
+ val socket = SocketHandler.getFriendsSocket();
+ val tracked = SocketHandler.getTrackedUsersSocket()
+ tracked?.off("userStatus")
socket?.off("friendRequest")
CoroutineScope(
@@ -32,26 +33,32 @@ object FriendStore {
}
}
+ Log.d("FriendStore", "initialized")
- socket?.on("userStatus") { it ->
+ tracked?.on("userStatus") { it ->
val jsonArray = it[0] as JSONObject
val payload = jsonArray.toString()
- Log.d("TPU.Untagged", payload)
+ Log.d("FriendStore.UserStatus", payload)
val status = SocketHandler.gson.fromJson(payload, StatusPayload::class.java)
val friend = friends.value.find { it.otherUser?.id == status.id }
+ Log.d("FriendStore.UserStatus", "friend: $friend")
+
if (friend != null) {
friends.value = friends.value.minus(friend).plus(
friend.copy(
otherUser = friend.otherUser?.copy(
- status = status.status ?: "offline",
+ status = status.status?.lowercase() ?: "offline",
platforms = status.platforms
)
)
)
} else if (status.id == UserStore.user.value?.id) {
+ Log.d("FriendStore.UserStatus", "updating current user status")
UserStore.user.value = UserStore.user.value?.copy(
- status = status.status ?: "offline",
+ status = status.status?.lowercase() ?: "offline",
+ storedStatus = if (status.status?.lowercase() === "offline") "invisible" else status.status?.lowercase()
+ ?: "offline",
platforms = status.platforms
)
}
@@ -87,7 +94,13 @@ object FriendStore {
fun updateFriendNickname(name: String, userId: Int) {
friends.value = friends.value.map {
if (it.otherUser?.id == userId) {
- it.copy(otherUser = it.otherUser?.copy(nickname = it.otherUser?.nickname?.copy(nickname = name)))
+ it.copy(
+ otherUser = it.otherUser?.copy(
+ nickname = it.otherUser?.nickname?.copy(
+ nickname = name
+ )
+ )
+ )
} else {
it
}
diff --git a/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt b/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt
index 7fdf584..43da8a7 100644
--- a/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt
+++ b/app/src/main/java/com/troplo/privateuploader/api/stores/UploadStore.kt
@@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
import androidx.core.app.ActivityCompat.startActivityForResult
import com.troplo.privateuploader.MainActivity
import com.troplo.privateuploader.UploadResponseActivity
@@ -16,7 +17,7 @@ import java.util.Date
object UploadStore {
var intentCode = 0
var filePath: String = ""
- var uploads = mutableListOf()
+ var uploads = mutableStateListOf()
var globalProgress: MutableState = mutableFloatStateOf(0f)
fun requestUploadIntent(activity: Activity) {
@@ -26,6 +27,6 @@ object UploadStore {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
type = "*/*"
}
- startActivityForResult(activity, intent, intentCode, null)
+ activity.startActivityForResult(intent, intentCode, null)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt
index 1388f0e..c1bb7a9 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/Attachment.kt
@@ -1,12 +1,18 @@
package com.troplo.privateuploader.components.chat
+import android.Manifest
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.GifBox
import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,18 +29,28 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.troplo.privateuploader.MainActivity
import com.troplo.privateuploader.api.ChatStore
+import com.troplo.privateuploader.api.stores.UploadStore
import com.troplo.privateuploader.api.stores.UserStore
import com.troplo.privateuploader.components.chat.attachment.MyDevice
import com.troplo.privateuploader.data.model.Upload
import com.troplo.privateuploader.data.model.UploadTarget
import com.troplo.privateuploader.screens.GalleryScreen
+import com.troplo.privateuploader.screens.getFileName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun Attachment(openBottomSheet: MutableState) {
val windowInsets = WindowInsets(0)
@@ -45,9 +61,24 @@ fun Attachment(openBottomSheet: MutableState) {
ModalBottomSheet(
onDismissRequest = { openBottomSheet.value = false },
sheetState = bottomSheetState,
- windowInsets = windowInsets,
+// windowInsets = windowInsets,
modifier = Modifier.defaultMinSize(minHeight = 400.dp)
) {
+ val context = LocalContext.current
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { data ->
+ Log.d("TPU.UploadResponse", "Upload response received, data: $data")
+ for (uri in data) {
+ UploadStore.uploads.add(
+ UploadTarget(
+ uri = uri,
+ started = false,
+ progress = 0f,
+ name = getFileName(uri, context)
+ )
+ )
+ }
+ }
+
TabRow(
selectedTabIndex = selectedTab.value,
contentColor = MaterialTheme.colorScheme.onSurface
@@ -55,7 +86,7 @@ fun Attachment(openBottomSheet: MutableState) {
Tab(
selected = selectedTab.value == 0,
onClick = { selectedTab.value = 0 },
- text = { Text("My Device") },
+ text = { Text("Device") },
icon = { Icon(Icons.Default.Smartphone, contentDescription = "My Device") }
)
Tab(
@@ -76,11 +107,24 @@ fun Attachment(openBottomSheet: MutableState) {
text = { Text("GIFs") },
icon = { Icon(Icons.Default.GifBox, contentDescription = "GIFs") }
)
+
+ val filesPermissionState = rememberPermissionState(
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ )
+
+ Tab(
+ selected = selectedTab.value == 4,
+ onClick = {
+ launcher.launch(arrayOf("*/*"))
+ },
+ text = { Text("Other") },
+ icon = { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open in File Manager") }
+ )
}
fun onClick(upload: Upload, tenor: Boolean = false) {
openBottomSheet.value = false
- ChatStore.attachmentsToUpload.add(
+ UploadStore.uploads.add(
UploadTarget(
uri = if (tenor) {
upload.attachment.toUri()
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt
index b4af568..3efdd4b 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatActions.kt
@@ -56,7 +56,7 @@ fun ChatActions(
ModalBottomSheet(
onDismissRequest = { openBottomSheet.value = false },
sheetState = bottomSheetState,
- windowInsets = windowInsets
+// windowInsets = windowInsets
) {
Column(
modifier = Modifier.padding(bottom = 8.dp),
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt
index e4bf41c..70d74b2 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/ChatItem.kt
@@ -1,6 +1,5 @@
package com.troplo.privateuploader.components.chat
-import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
@@ -9,16 +8,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.troplo.privateuploader.api.ChatStore
import com.troplo.privateuploader.api.TpuFunctions
+import com.troplo.privateuploader.components.core.NavRoute
import com.troplo.privateuploader.components.core.UserAvatar
import com.troplo.privateuploader.data.model.Chat
@@ -26,7 +25,7 @@ import com.troplo.privateuploader.data.model.Chat
@Composable
fun ChatItem(
chat: Chat,
- openChat: (Int) -> Unit
+ navController: NavController,
) {
val chatName = TpuFunctions.getChatName(chat)
// track ChatStore.associationId, is mutableStateOf(0)
@@ -55,7 +54,9 @@ fun ChatItem(
.fillMaxWidth(),
onClick = {
chat.association?.let {
- openChat(it.id)
+ if (navController.currentDestination?.route?.startsWith("chat/") == false || id.value != it.id) {
+ navController.navigate("${NavRoute.Chat.path}/${it.id}")
+ }
}
},
label = {
@@ -78,11 +79,12 @@ fun ChatItem(
)
}*/
},
- selected = id.value == chat.association?.id,
+ selected = navController.currentDestination?.route?.startsWith("chat/") == true && id.value == chat.association?.id,
icon = {
UserAvatar(
avatar = chat.icon ?: chat.recipient?.avatar,
- username = chatName
+ username = chat.recipient?.username ?: chatName,
+ showStatus = chat.recipient != null
)
}
)
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt
index 191bba4..ff244e5 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/Embed.kt
@@ -2,15 +2,14 @@ package com.troplo.privateuploader.components.chat
import android.content.Intent
import android.net.Uri
-import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -28,143 +27,165 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
-import coil.compose.rememberAsyncImagePainter
-import coil.request.ImageRequest
-import coil.size.Size
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
+import com.skydoves.landscapist.ImageOptions
+import com.skydoves.landscapist.glide.GlideImage
import com.troplo.privateuploader.api.TpuFunctions
-import com.troplo.privateuploader.api.imageLoader
+import com.troplo.privateuploader.api.stores.CoreStore
import com.troplo.privateuploader.api.stores.UserStore
import com.troplo.privateuploader.components.chat.dialogs.ImageDialog
import com.troplo.privateuploader.data.model.Embed
-import kotlinx.coroutines.Dispatchers
+import com.troplo.privateuploader.data.model.EmbedDataV2
+import com.troplo.privateuploader.data.model.EmbedMedia
+import com.troplo.privateuploader.data.model.EmbedMediaType
@OptIn(ExperimentalGlideComposeApi::class)
@Composable
-fun Embed(embed: Embed) {
- if (embed.data != null) {
- val url =
- if (embed.data.type == "TPU_DIRECT") embed.data.url else "https://" + UserStore.getUser()?.domain?.domain + embed.data.url
- when (embed.type) {
- "openGraph" -> {
+fun Embed(embed: EmbedDataV2) {
+ if (embed.metadata != null) {
+ val context = LocalContext.current
+ val coreStore = CoreStore.core.value
+ if (embed.metadata.siteName?.isNotEmpty() == true) {
+ Card {
+ Column {
+ Text(
+ text = embed.metadata.siteName.orEmpty(),
+ modifier = Modifier.padding(top = 8.dp),
+ style = MaterialTheme.typography.bodySmall
+ )
+ EmbedText(embed)
+ }
+ }
+ }
+
+ if (embed.metadata.siteName?.isEmpty() == true) {
Card {
Column {
- Text(
- text = embed.data.siteName.orEmpty(),
- modifier = Modifier.padding(top = 8.dp),
- style = MaterialTheme.typography.bodySmall
- )
- Text(
- text = embed.data.title.orEmpty(),
- modifier = Modifier.padding(top = 8.dp),
- style = MaterialTheme.typography.bodyLarge
- )
- Text(
- text = embed.data.description.orEmpty(),
- modifier = Modifier.padding(top = 8.dp),
- style = MaterialTheme.typography.bodyMedium
- )
+ EmbedText(embed)
}
}
}
- "image" -> {
- val expand = remember { mutableStateOf(false) }
- if (expand.value) {
- ImageDialog(url ?: "", embed.data.upload?.name ?: "unknown.png", expand)
- }
- Image(
- contentDescription = "Embed image (no alt text)",
- painter = rememberAsyncImagePainter(
- ImageRequest.Builder(LocalContext.current)
- .dispatcher(Dispatchers.IO)
- .data(data = url)
- .apply(block = fun ImageRequest.Builder.() {
- size(Size.ORIGINAL)
- }).build(), imageLoader = imageLoader(LocalContext.current, false)
- ),
- modifier = Modifier
- .fillMaxWidth()
- .height(if (embed.data.height!! > 300) 300.dp else embed.data.height.dp)
- .clickable {
- expand.value = true
- }
- )
-
- }
+ for (media in embed.media ?: emptyList()) {
+ if (media.type == 0) {
+ val url =
+ if (!media.isInternal) "https://${coreStore?.domain ?: "https://flowinity.com/"}${media.proxyUrl}" else "https://" + UserStore.getUser()?.domain?.domain + "/i/" + media.attachment
+ val expand = remember { mutableStateOf(false) }
+ if (expand.value) {
+ ImageDialog(
+ url ?: "",
+ media.upload?.name ?: media.attachment ?: media.url ?: "unknown.png",
+ expand
+ )
+ }
- "file" -> {
- val context = LocalContext.current
- Card {
- Column {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = 16.dp, top = 16.dp)
- ) {
- Icon(
- imageVector = Icons.Default.InsertDriveFile,
- contentDescription = null,
- modifier = Modifier.size(48.dp)
- )
- Text(
- text = embed.data.upload?.name.orEmpty(),
- modifier = Modifier.padding(start = 8.dp),
- style = MaterialTheme.typography.bodyMedium
- )
- }
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = 16.dp)
- ) {
- Text(
- text = TpuFunctions.fileSize(embed.data.upload?.fileSize),
- style = MaterialTheme.typography.bodySmall,
- color = Color.Gray,
- modifier = Modifier.weight(1f)
- )
- Spacer(modifier = Modifier.weight(0.1f))
- IconButton(
- onClick = {
- val attachment = embed.data.upload?.attachment
- if (attachment != null) {
+ GlideImage(
+ imageModel = { url },
+ modifier = Modifier
+ .fillMaxSize()
+ .height(if (media.height!! > 300) 300.dp else media.height.dp)
+ .clickable {
+ expand.value = true
+ },
+ imageOptions = ImageOptions(
+ contentScale = ContentScale.FillWidth
+ )
+ )
+ } else if ((media.type == 3 || media.type == 1) && media.upload != null) {
+ Card {
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 16.dp, top = 16.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.InsertDriveFile,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp)
+ )
+ Text(
+ text = media.upload.name,
+ modifier = Modifier.padding(start = 8.dp),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 16.dp)
+ ) {
+ Text(
+ text = TpuFunctions.fileSize(media.upload.fileSize),
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.Gray,
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.weight(0.1f))
+ IconButton(
+ onClick = {
+ val attachment = media.upload.attachment
val intent = Intent(Intent.ACTION_VIEW)
intent.data =
Uri.parse("https://${UserStore.getUser()?.domain?.domain}/i/${attachment}?force=true")
context.startActivity(intent)
}
+ ) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = null
+ )
}
- ) {
- Icon(
- imageVector = Icons.Default.Download,
- contentDescription = null
- )
}
}
}
+ } else if(media.type == 3 || media.type == 1) {
+ Card(
+ modifier = Modifier
+ .width(300.dp)
+ .heightIn(0.dp, 200.dp)
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "This file has been deleted by the owner.",
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+ } else {
+ Card(
+ modifier = Modifier
+ .width(300.dp)
+ .heightIn(0.dp, 200.dp)
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "This embed cannot be loaded.\n\nType: ${media.type}\n\nURL: ${media.url}",
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
}
}
-
- else -> {
- Card {
- Text(
- text = "The version of TPUvNATIVE you are using does not yet support the embed type ${embed.type}!",
- modifier = Modifier.padding(16.dp)
- )
- }
- }
- }
- } else {
- Card(
- modifier = Modifier.width(300.dp)
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
+ } else {
+ Card(
+ modifier = Modifier
+ .width(300.dp)
+ .heightIn(0.dp, 200.dp)
) {
- Text(text = "This embed cannot be loaded.", modifier = Modifier.padding(16.dp))
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = "This embed cannot be loaded.", modifier = Modifier.padding(16.dp))
+ }
}
}
- }
-}
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/EmbedText.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/EmbedText.kt
new file mode 100644
index 0000000..9f54867
--- /dev/null
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/EmbedText.kt
@@ -0,0 +1,21 @@
+package com.troplo.privateuploader.components.chat
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Text
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
+import com.troplo.privateuploader.data.model.EmbedDataV2
+
+@Composable
+fun EmbedText(embed: EmbedDataV2) {
+ for (text in embed.text ?: emptyList()) {
+ Text(
+ text = text.text,
+ modifier = Modifier.padding(top = 8.dp),
+ style = if(text.heading == true) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt
index a531698..2e176ae 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownText.kt
@@ -27,7 +27,6 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
-import com.troplo.privateuploader.TpuApp
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
@@ -35,13 +34,11 @@ import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.linkify.LinkifyPlugin
-import io.wax911.emojify.parser.parseToHtmlDecimal
-import io.wax911.emojify.parser.parseToUnicode
@Composable
fun MarkdownText(
- markdown: String,
+ content: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
@@ -53,7 +50,7 @@ fun MarkdownText(
onClick: (() -> Unit)? = null,
// this option will disable all clicks on links, inside the markdown text
// it also enable the parent view to receive the click event
- onLinkClicked: ((String) -> Unit)? = null,
+ onLinkClicked: (() -> Unit)? = null,
onTextLayout: ((numLines: Int) -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
@@ -75,12 +72,11 @@ fun MarkdownText(
maxLines = maxLines,
style = style,
textAlign = textAlign,
- viewId = viewId,
- onClick = onClick
+ viewId = viewId
)
},
update = { textView ->
- markdownRender.setMarkdown(textView, markdown)
+ markdownRender.setMarkdown(textView, content)
if (onTextLayout != null) {
textView.post {
onTextLayout(textView.lineCount)
@@ -88,8 +84,11 @@ fun MarkdownText(
}
textView.maxLines = maxLines
textView.setOnLongClickListener {
+ Log.d("MarkdownText", "onLongClick")
preventLinkClick.value = true
- onLongClick?.invoke()
+ if (onLongClick != null) {
+ onLongClick()
+ }
true
}
}
@@ -114,8 +113,7 @@ private fun createTextView(
TextStyle(
color = textColor,
fontSize = if (fontSize != TextUnit.Unspecified) fontSize else style.fontSize,
- textAlign = textAlign,
-
+ textAlign = textAlign ?: style.textAlign
)
)
return TextView(context).apply {
@@ -147,7 +145,7 @@ private fun createTextView(
private fun createMarkdownRender(
context: Context,
- onLinkClicked: ((String) -> Unit)? = null,
+ onLinkClicked: (() -> Unit)? = null,
preventLinkClick: MutableState,
): Markwon {
return Markwon.builder(context)
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownTextV2.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownTextV2.kt
new file mode 100644
index 0000000..16565ea
--- /dev/null
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MarkdownTextV2.kt
@@ -0,0 +1,306 @@
+package com.troplo.privateuploader.components.chat
+
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.sp
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.Text
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+
+// Regex containing the syntax tokens
+val symbolPattern by lazy {
+ Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""")
+}
+
+// Accepted annotations for the ClickableTextWrapper
+enum class SymbolAnnotationType {
+ USER, LINK, COLLECTION
+}
+
+typealias StringAnnotation = AnnotatedString.Range
+// Pair returning styled content and annotation for ClickableText when matching syntax token
+typealias SymbolAnnotation = Pair
+
+@Composable
+fun MarkdownTextV2(modifier: Modifier = Modifier, content: String, color: Color = LocalContentColor.current, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null) {
+ val context = LocalContext.current
+ val styledMessage = ProcessMessageText(
+ messageText = content
+ )
+
+ ClickableText(
+ text = styledMessage,
+ style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
+ modifier = Modifier.pointerInput(Unit) {
+ detectTapGestures(
+ onLongPress = {
+ if(onLongClick != null) {
+ onLongClick()
+ }
+ },
+ onTap = {
+
+ }
+ )
+ }.then(modifier),
+ onClick = {
+ if(onClick == null) {
+ styledMessage
+ .getStringAnnotations(start = it, end = it)
+ .firstOrNull()
+ ?.let { annotation ->
+ when (annotation.tag) {
+ SymbolAnnotationType.LINK.name -> {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
+ context.startActivity(intent)
+ }
+
+ SymbolAnnotationType.USER.name -> {
+ Log.d("MarkdownText", "User annotation clicked")
+ }
+
+ else -> Unit
+ }
+ }
+ } else {
+ onClick()
+ }
+ }
+ )
+}
+
+@Composable
+fun c(
+ text: String
+): AnnotatedString {
+ val tokens = symbolPattern.findAll(text)
+
+ return buildAnnotatedString {
+
+ var cursorPosition = 0
+
+ val codeSnippetBackground = MaterialTheme.colorScheme.surface
+
+ for (token in tokens) {
+ append(text.slice(cursorPosition until token.range.first))
+
+ val (annotatedString, stringAnnotation) = getSymbolAnnotation(
+ matchResult = token,
+ colorScheme = MaterialTheme.colorScheme,
+ codeSnippetBackground = codeSnippetBackground
+ )
+ append(annotatedString)
+
+ if (stringAnnotation != null) {
+ val (item, start, end, tag) = stringAnnotation
+ addStringAnnotation(tag = tag, start = start, end = end, annotation = item)
+ }
+
+ cursorPosition = token.range.last + 1
+ }
+
+ if (!tokens.none()) {
+ append(text.slice(cursorPosition..text.lastIndex))
+ } else {
+ append(text)
+ }
+ }
+}
+
+/**
+ * Map regex matches found in a message with supported syntax symbols
+ *
+ * @param matchResult is a regex result matching our syntax symbols
+ * @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper
+ */
+private fun getSymbolAnnotation(
+ matchResult: MatchResult,
+ colorScheme: ColorScheme,
+ codeSnippetBackground: Color
+): SymbolAnnotation {
+ return when (matchResult.value.first()) {
+ '*' -> SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('*'),
+ spanStyle = SpanStyle(
+ fontWeight = if (matchResult.value.length == 2) FontWeight.Bold else FontWeight.Normal,
+ fontStyle = if (matchResult.value.length == 2) FontStyle.Normal else FontStyle.Italic
+ )
+ ),
+ null
+ )
+ '_' -> SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('_'),
+ spanStyle = SpanStyle(fontStyle = FontStyle.Italic)
+ ),
+ null
+ )
+ '~' -> SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value.trim('~'),
+ spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough)
+ ),
+ null
+ )
+ '`' -> {
+ val snippetText = matchResult.value.removeSurrounding("`")
+ SymbolAnnotation(
+ AnnotatedString(
+ text = snippetText,
+ spanStyle = SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ background = codeSnippetBackground,
+ baselineShift = BaselineShift(0.2f)
+ )
+ ),
+ null
+ )
+ }
+ '<' -> {
+ val numericalId = matchResult.value.substringAfter("<@").substringBefore(">")
+ Log.d("MarkdownText", "Numerical ID: $numericalId")
+ SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value,
+ spanStyle = SpanStyle(
+ color = colorScheme.primary
+ )
+ ),
+ StringAnnotation(
+ item = numericalId,
+ start = matchResult.range.first,
+ end = matchResult.range.last,
+ tag = SymbolAnnotationType.USER.name
+ )
+ )
+ }
+ 'h' -> SymbolAnnotation(
+ AnnotatedString(
+ text = matchResult.value,
+ spanStyle = SpanStyle(
+ color = colorScheme.primary
+ )
+ ),
+ StringAnnotation(
+ item = matchResult.value,
+ start = matchResult.range.first,
+ end = matchResult.range.last,
+ tag = SymbolAnnotationType.LINK.name
+ )
+ )
+ else -> SymbolAnnotation(AnnotatedString(matchResult.value), null)
+ }
+}
+
+@Composable
+fun ProcessMessageText(messageText: String): AnnotatedString {
+ val hyperlinkRegex = "<@(\\d+)>".toRegex()
+ val italicRegex = "\\*(.*?)\\*".toRegex()
+ val boldRegex = "\\*\\*(.*?)\\*\\*".toRegex()
+ val strikethroughRegex = "~~(.*?)~~".toRegex()
+
+ val matches = mutableListOf()
+
+ matches.addAll(hyperlinkRegex.findAll(messageText))
+ matches.addAll(italicRegex.findAll(messageText))
+ matches.addAll(boldRegex.findAll(messageText))
+ matches.addAll(strikethroughRegex.findAll(messageText))
+
+ matches.sortBy { it.range.first }
+
+ var currentIndex = 0
+ var processedText = ""
+
+ matches.forEach { match ->
+ val startIndex = match.range.first
+ val endIndex = match.range.last + 1
+ if(startIndex < currentIndex) return@forEach
+ // Append the text before the formatting tag
+ processedText += messageText.substring(currentIndex, startIndex)
+
+ // Process the matched tag
+ val tag = match.value
+ val content = match.groupValues[1]
+
+ processedText += when (tag) {
+ in hyperlinkRegex.pattern -> processHyperlink(content)
+ in italicRegex.pattern -> processItalic(content)
+ in boldRegex.pattern -> processBold(content)
+ in strikethroughRegex.pattern -> processStrikethrough(content)
+ else -> content // Should not reach here, but include for safety
+ }
+
+ currentIndex = endIndex
+ }
+
+ // Append the remaining text
+ processedText += messageText.substring(currentIndex)
+
+ // Display the processed text
+ return buildAnnotatedString { append(processedText) }
+}
+
+@Composable
+fun processHyperlink(content: String): AnnotatedString {
+ val id = content.trim()
+ val username = "test"
+
+ return buildAnnotatedString {
+ withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
+ append(username)
+ }
+ withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface)) {
+ append(" ")
+ append(id)
+ }
+ }
+}
+
+@Composable
+fun processItalic(content: String): AnnotatedString {
+ return buildAnnotatedString {
+ withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
+ append(content)
+ }
+ }
+}
+
+@Composable
+fun processBold(content: String): AnnotatedString {
+ return buildAnnotatedString {
+ withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(content)
+ }
+ }
+}
+
+@Composable
+fun processStrikethrough(content: String): AnnotatedString {
+ return buildAnnotatedString {
+ withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
+ append(content)
+ }
+ }
+}
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt
index e0d1ea9..04dad92 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/MemberSidebar.kt
@@ -1,5 +1,6 @@
package com.troplo.privateuploader.components.chat
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -13,6 +14,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -36,9 +39,9 @@ import com.troplo.privateuploader.components.user.UserPopup
fun MemberSidebar() {
val chatActions = remember { mutableStateOf(false) }
val chatId = ChatStore.associationId.collectAsState()
- val chats = ChatStore.chats.collectAsState()
+ val chats = remember { ChatStore.chats }
val chat =
- remember { derivedStateOf { chats.value.find { it.association?.id == chatId.value } } }
+ remember { derivedStateOf { chats.find { it.association?.id == chatId.value } } }
val user: MutableState = remember { mutableStateOf(null) }
val popup = remember { mutableStateOf(false) }
val pins = remember { mutableStateOf(false) }
@@ -55,11 +58,14 @@ fun MemberSidebar() {
PinsDialog(pins)
}
- Column {
+ Column(
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).fillMaxSize()
+ ) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
+ .background(MaterialTheme.colorScheme.surfaceContainer)
.fillMaxSize()
) {
IconButton(
@@ -90,7 +96,10 @@ fun MemberSidebar() {
}
}
ListItem(
- headlineContent = { Text("Members") }
+ headlineContent = { Text("Members") },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ )
)
if (chat.value != null) {
@@ -109,7 +118,8 @@ fun MemberSidebar() {
popup.value = true
}
},
- selected = false
+ selected = false,
+ modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
)
}
}
diff --git a/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt b/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt
index 2d953a7..cad289e 100644
--- a/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt
+++ b/app/src/main/java/com/troplo/privateuploader/components/chat/Message.kt
@@ -15,53 +15,40 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.Reply
import androidx.compose.material3.Divider
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.troplo.privateuploader.api.TpuFunctions
import com.troplo.privateuploader.components.core.UserAvatar
-import com.troplo.privateuploader.data.model.ChatAssociation
import com.troplo.privateuploader.data.model.Embed
import com.troplo.privateuploader.data.model.EmbedData
import com.troplo.privateuploader.data.model.Message
import com.troplo.privateuploader.data.model.defaultUser
import com.troplo.privateuploader.ui.theme.PrivateUploaderTheme
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalGlideComposeApi::class)
@Composable
fun Message(
+ modifier: Modifier = Modifier,
message: Message,
compact: String = "none",
- messageCtx: MutableState?,
- messageCtxMessage: MutableState?,
- modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onReply: ((replyId: Int) -> Unit)? = null,
+ onLongClick: (() -> Unit)? = null,
) {
Column(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
- onLongPress = {
- if (messageCtx == null || messageCtxMessage == null) return@detectTapGestures
- messageCtxMessage.value = message
- messageCtx.value = true
- },
onTap = {
if (onClick != null) onClick()
}
@@ -179,17 +166,13 @@ fun Message(
modifier = Modifier.weight(1f).fillMaxWidth()
) {
MarkdownText(
- markdown = message.content,
+ content = message.content,
color = color,
- onLongClick = {
- if (messageCtx == null || messageCtxMessage == null) return@MarkdownText
- messageCtxMessage.value = message
- messageCtx.value = true
- },
onClick = {
if (onClick != null) onClick()
},
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
+ onLongClick = onLongClick
)
if (message.edited && !normal) {
val context = LocalContext.current
@@ -229,11 +212,7 @@ fun Message(
}
} else {
Box(
- modifier = Modifier.padding(start = 4.dp).clickable {
- if (messageCtx == null || messageCtxMessage == null) return@clickable
- messageCtxMessage.value = message
- messageCtx.value = true
- }
+ modifier = Modifier.padding(start = 4.dp)
) {
UserAvatar(
avatar = null,
@@ -246,6 +225,7 @@ fun Message(
}
}
}
+
message.embeds.forEach {
Embed(embed = it)
}
@@ -270,21 +250,7 @@ fun MessagePreview() {
updatedAt = "2021-09-01T00:00:00.000Z",
edited = true,
editedAt = null,
- embeds = listOf(
- Embed(
- data = EmbedData(
- type = "image",
- description = "yes",
- height = 69,
- siteName = "TPU",
- title = "TPU",
- upload = null,
- url = "https://i.troplo.com",
- width = 420
- ),
- type = "image",
- )
- ),
+ embeds = emptyList(),
error = false,
legacyUser = null,
legacyUserId = null,
@@ -300,7 +266,7 @@ fun MessagePreview() {
updatedAt = "2021-09-01T00:00:00.000Z",
edited = false,
editedAt = null,
- embeds = emptyList