rebr
This commit is contained in:
parent
fb90a4714f
commit
b9f82755a5
661
AGPL-3
661
AGPL-3
|
@ -1,661 +0,0 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Pleroma
|
||||
Copyright (C) 2017 Roger Braun
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
395
CC-BY-4.0
395
CC-BY-4.0
|
@ -1,395 +0,0 @@
|
|||
Attribution 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution 4.0 International Public License ("Public License"). To the
|
||||
extent this Public License may be interpreted as a contract, You are
|
||||
granted the Licensed Rights in consideration of Your acceptance of
|
||||
these terms and conditions, and the Licensor grants You such rights in
|
||||
consideration of benefits the Licensor receives from making the
|
||||
Licensed Material available under these terms and conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
d. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
f. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
g. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
i. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
j. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
k. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the Adapted
|
||||
Material from complying with this Public License.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
427
CC-BY-SA-4.0
427
CC-BY-SA-4.0
|
@ -1,427 +0,0 @@
|
|||
Attribution-ShareAlike 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International Public
|
||||
License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-ShareAlike 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
l. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
m. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
|
@ -1,59 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
This file is only for changes to Soapbox.
|
||||
For changes to Pleroma, see `CHANGELOG.md`
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [3.0.0] - 2022-05-24
|
||||
|
||||
Based on Pleroma 2.4 develop.
|
||||
|
||||
TODO: a full changelog
|
||||
|
||||
### Added
|
||||
|
||||
- Events
|
||||
- Translations
|
||||
|
||||
## [1.1.1] - 2021-05-21
|
||||
|
||||
Based on Pleroma 2.3.0-stable.
|
||||
|
||||
### Fixed
|
||||
- Broken frontend due to not all files being in the repo.
|
||||
|
||||
## [1.1.0] - 2021-05-18
|
||||
|
||||
Based on Pleroma 2.3.0-stable.
|
||||
|
||||
### Added
|
||||
- Retain uploaded image aspect ratios. ([!18](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/18))
|
||||
- Blurhash support. ([!21](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/21))
|
||||
|
||||
### Fixed
|
||||
- Rich media not working for certain links. ([!19](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/19), [!20](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/20))
|
||||
|
||||
### Changed
|
||||
- Prepared installation files for use with asdf version manager. ([!22](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/22), [!23](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/23), [!24](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/24))
|
||||
- **BREAKING:** `imagemagick` and `exiftool` are required to be installed on the system.
|
||||
|
||||
## [1.0.0] - 2021-05-11
|
||||
|
||||
Based on Pleroma 2.3.0-stable.
|
||||
|
||||
### Added
|
||||
- Rich media embeds for sites like YouTube, etc. ([!13](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/13))
|
||||
- Twitter-like block behavior, configured under "ActivityPub > Blockers visible" in AdminFE. ([!9](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/9))
|
||||
- The Soapbox version in `/api/v1/instance` ([!6](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/6))
|
||||
|
||||
### Changed
|
||||
- Soapbox FE is set as the default frontend. ([!16](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/16))
|
||||
- Twitter-like block behavior is now the default. ([!9](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/9))
|
||||
|
||||
### Fixed
|
||||
- Domain blocks: reposts from a blocked domain are now correctly blocked. ([!11](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/11))
|
||||
- Fixed some (not all) Markdown issues, such as broken trailing slash in links. ([!10](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/10))
|
||||
- Don't crash so hard when email settings are invalid. ([!12](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/12))
|
||||
- Return OpenGraph metadata on Soapbox FE routes. ([!14](https://gitlab.com/soapbox-pub/soapbox/-/merge_requests/14))
|
56
COPYING
56
COPYING
|
@ -1,56 +0,0 @@
|
|||
Unless otherwise stated this repository is copyright © 2017-2022
|
||||
Pleroma Authors <https://pleroma.social/>, and is distributed under
|
||||
The GNU Affero General Public License Version 3, you should have received a
|
||||
copy of the license file as AGPL-3.
|
||||
|
||||
---
|
||||
|
||||
Files inside docs directory are copyright © 2022 Pleroma Authors
|
||||
<https://pleroma.social/>, and are distributed under the Creative Commons
|
||||
Attribution 4.0 International license, you should have received
|
||||
a copy of the license file as CC-BY-4.0.
|
||||
|
||||
---
|
||||
|
||||
The following files are copyright © 2019 shitposter.club, and are distributed
|
||||
under the Creative Commons Attribution-ShareAlike 4.0 International license,
|
||||
you should have received a copy of the license file as CC-BY-SA-4.0.
|
||||
|
||||
priv/static/images/pleroma-fox-tan.png
|
||||
priv/static/images/pleroma-fox-tan-smol.png
|
||||
priv/static/images/pleroma-tan.png
|
||||
|
||||
---
|
||||
|
||||
The following files are copyright © 2019 shitposter.club, and are distributed
|
||||
under the Creative Commons Attribution 4.0 International license, you should
|
||||
have received a copy of the license file as CC-BY-4.0.
|
||||
|
||||
priv/static/images/pleroma-fox-tan-shy.png
|
||||
|
||||
---
|
||||
|
||||
The following files are copyright © 2017-2022 Pleroma Authors
|
||||
<https://pleroma.social/>, and are distributed under the Creative Commons
|
||||
Attribution-ShareAlike 4.0 International license, you should have received
|
||||
a copy of the license file as CC-BY-SA-4.0.
|
||||
|
||||
priv/static/images/avi.png
|
||||
priv/static/images/banner.png
|
||||
priv/static/instance/thumbnail.jpeg
|
||||
|
||||
---
|
||||
|
||||
All photos published on Unsplash can be used for free. You can use them for
|
||||
commercial and noncommercial purposes. You do not need to ask permission from
|
||||
or provide credit to the photographer or Unsplash, although it is appreciated
|
||||
when possible.
|
||||
|
||||
More precisely, Unsplash grants you an irrevocable, nonexclusive, worldwide
|
||||
copyright license to download, copy, modify, distribute, perform, and use
|
||||
photos from Unsplash for free, including for commercial purposes, without
|
||||
permission from or attributing the photographer or Unsplash. This license
|
||||
does not include the right to compile photos from Unsplash to replicate
|
||||
a similar or competing service.
|
||||
|
||||
priv/static/images/city.jpg
|
16
SECURITY.md
16
SECURITY.md
|
@ -1,16 +0,0 @@
|
|||
# Pleroma backend security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Currently, Pleroma offers bugfixes and security patches only for the latest minor release.
|
||||
|
||||
| Version | Support
|
||||
|---------| --------
|
||||
| 2.2 | Bugfixes and security patches
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please use confidential issues (tick the "This issue is confidential and should only be visible to team members with at least Reporter access." box when submitting) at our [bugtracker](https://git.pleroma.social/pleroma/pleroma/-/issues/new) for reporting vulnerabilities.
|
||||
## Announcements
|
||||
|
||||
New releases are announced at [pleroma.social](https://pleroma.social/announcements/). All security releases are tagged with ["Security"](https://pleroma.social/announcements/tags/security/). You can be notified of them by subscribing to an Atom feed at <https://pleroma.social/announcements/tags/security/feed.xml>.
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"note": "patriots 900000001",
|
||||
"discoverable": true,
|
||||
"id": "109989480368015378",
|
||||
"domain": null,
|
||||
"avatar": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
|
||||
"avatar_static": "https://media.covfefe.social/groups/avatars/109/989/480/368/015/378/original/50b0d899bc5aae13.jpg",
|
||||
"header": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
|
||||
"header_static": "https://media.covfefe.social/groups/headers/109/989/480/368/015/378/original/c5063b59f919cd4a.png",
|
||||
"group_visibility": "everyone",
|
||||
"created_at": "2023-03-08T00:00:00.000Z",
|
||||
"display_name": "PATRIOT PATRIOTS",
|
||||
"membership_required": true,
|
||||
"members_count": 1,
|
||||
"tags": []
|
||||
}
|
|
@ -1,752 +0,0 @@
|
|||
import { deleteEntities } from 'soapbox/entity-store/actions';
|
||||
|
||||
import api, { getLinks } from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedGroups, importFetchedAccounts } from './importer';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { GroupRole } from 'soapbox/reducers/group-memberships';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const GROUP_CREATE_REQUEST = 'GROUP_CREATE_REQUEST';
|
||||
const GROUP_CREATE_SUCCESS = 'GROUP_CREATE_SUCCESS';
|
||||
const GROUP_CREATE_FAIL = 'GROUP_CREATE_FAIL';
|
||||
|
||||
const GROUP_UPDATE_REQUEST = 'GROUP_UPDATE_REQUEST';
|
||||
const GROUP_UPDATE_SUCCESS = 'GROUP_UPDATE_SUCCESS';
|
||||
const GROUP_UPDATE_FAIL = 'GROUP_UPDATE_FAIL';
|
||||
|
||||
const GROUP_DELETE_REQUEST = 'GROUP_DELETE_REQUEST';
|
||||
const GROUP_DELETE_SUCCESS = 'GROUP_DELETE_SUCCESS';
|
||||
const GROUP_DELETE_FAIL = 'GROUP_DELETE_FAIL';
|
||||
|
||||
const GROUP_FETCH_REQUEST = 'GROUP_FETCH_REQUEST';
|
||||
const GROUP_FETCH_SUCCESS = 'GROUP_FETCH_SUCCESS';
|
||||
const GROUP_FETCH_FAIL = 'GROUP_FETCH_FAIL';
|
||||
|
||||
const GROUPS_FETCH_REQUEST = 'GROUPS_FETCH_REQUEST';
|
||||
const GROUPS_FETCH_SUCCESS = 'GROUPS_FETCH_SUCCESS';
|
||||
const GROUPS_FETCH_FAIL = 'GROUPS_FETCH_FAIL';
|
||||
|
||||
const GROUP_RELATIONSHIPS_FETCH_REQUEST = 'GROUP_RELATIONSHIPS_FETCH_REQUEST';
|
||||
const GROUP_RELATIONSHIPS_FETCH_SUCCESS = 'GROUP_RELATIONSHIPS_FETCH_SUCCESS';
|
||||
const GROUP_RELATIONSHIPS_FETCH_FAIL = 'GROUP_RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
const GROUP_KICK_REQUEST = 'GROUP_KICK_REQUEST';
|
||||
const GROUP_KICK_SUCCESS = 'GROUP_KICK_SUCCESS';
|
||||
const GROUP_KICK_FAIL = 'GROUP_KICK_FAIL';
|
||||
|
||||
const GROUP_BLOCKS_FETCH_REQUEST = 'GROUP_BLOCKS_FETCH_REQUEST';
|
||||
const GROUP_BLOCKS_FETCH_SUCCESS = 'GROUP_BLOCKS_FETCH_SUCCESS';
|
||||
const GROUP_BLOCKS_FETCH_FAIL = 'GROUP_BLOCKS_FETCH_FAIL';
|
||||
|
||||
const GROUP_BLOCKS_EXPAND_REQUEST = 'GROUP_BLOCKS_EXPAND_REQUEST';
|
||||
const GROUP_BLOCKS_EXPAND_SUCCESS = 'GROUP_BLOCKS_EXPAND_SUCCESS';
|
||||
const GROUP_BLOCKS_EXPAND_FAIL = 'GROUP_BLOCKS_EXPAND_FAIL';
|
||||
|
||||
const GROUP_BLOCK_REQUEST = 'GROUP_BLOCK_REQUEST';
|
||||
const GROUP_BLOCK_SUCCESS = 'GROUP_BLOCK_SUCCESS';
|
||||
const GROUP_BLOCK_FAIL = 'GROUP_BLOCK_FAIL';
|
||||
|
||||
const GROUP_UNBLOCK_REQUEST = 'GROUP_UNBLOCK_REQUEST';
|
||||
const GROUP_UNBLOCK_SUCCESS = 'GROUP_UNBLOCK_SUCCESS';
|
||||
const GROUP_UNBLOCK_FAIL = 'GROUP_UNBLOCK_FAIL';
|
||||
|
||||
const GROUP_PROMOTE_REQUEST = 'GROUP_PROMOTE_REQUEST';
|
||||
const GROUP_PROMOTE_SUCCESS = 'GROUP_PROMOTE_SUCCESS';
|
||||
const GROUP_PROMOTE_FAIL = 'GROUP_PROMOTE_FAIL';
|
||||
|
||||
const GROUP_DEMOTE_REQUEST = 'GROUP_DEMOTE_REQUEST';
|
||||
const GROUP_DEMOTE_SUCCESS = 'GROUP_DEMOTE_SUCCESS';
|
||||
const GROUP_DEMOTE_FAIL = 'GROUP_DEMOTE_FAIL';
|
||||
|
||||
const GROUP_MEMBERSHIPS_FETCH_REQUEST = 'GROUP_MEMBERSHIPS_FETCH_REQUEST';
|
||||
const GROUP_MEMBERSHIPS_FETCH_SUCCESS = 'GROUP_MEMBERSHIPS_FETCH_SUCCESS';
|
||||
const GROUP_MEMBERSHIPS_FETCH_FAIL = 'GROUP_MEMBERSHIPS_FETCH_FAIL';
|
||||
|
||||
const GROUP_MEMBERSHIPS_EXPAND_REQUEST = 'GROUP_MEMBERSHIPS_EXPAND_REQUEST';
|
||||
const GROUP_MEMBERSHIPS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIPS_EXPAND_SUCCESS';
|
||||
const GROUP_MEMBERSHIPS_EXPAND_FAIL = 'GROUP_MEMBERSHIPS_EXPAND_FAIL';
|
||||
|
||||
const GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST';
|
||||
const GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS';
|
||||
const GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL';
|
||||
|
||||
const GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST';
|
||||
const GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS';
|
||||
const GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL = 'GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL';
|
||||
|
||||
const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST';
|
||||
const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS';
|
||||
const GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL = 'GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL';
|
||||
|
||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST = 'GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST';
|
||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS = 'GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS';
|
||||
const GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL = 'GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL';
|
||||
|
||||
const deleteGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(deleteEntities([id], 'Group'));
|
||||
|
||||
return api(getState).delete(`/api/v1/groups/${id}`)
|
||||
.then(() => dispatch(deleteGroupSuccess(id)))
|
||||
.catch(err => dispatch(deleteGroupFail(id, err)));
|
||||
};
|
||||
|
||||
const deleteGroupRequest = (id: string) => ({
|
||||
type: GROUP_DELETE_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const deleteGroupSuccess = (id: string) => ({
|
||||
type: GROUP_DELETE_SUCCESS,
|
||||
id,
|
||||
});
|
||||
|
||||
const deleteGroupFail = (id: string, error: AxiosError) => ({
|
||||
type: GROUP_DELETE_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchGroup = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchGroupRelationships([id]));
|
||||
dispatch(fetchGroupRequest(id));
|
||||
|
||||
return api(getState).get(`/api/v1/groups/${id}`)
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedGroups([data]));
|
||||
dispatch(fetchGroupSuccess(data));
|
||||
})
|
||||
.catch(err => dispatch(fetchGroupFail(id, err)));
|
||||
};
|
||||
|
||||
const fetchGroupRequest = (id: string) => ({
|
||||
type: GROUP_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const fetchGroupSuccess = (group: APIEntity) => ({
|
||||
type: GROUP_FETCH_SUCCESS,
|
||||
group,
|
||||
});
|
||||
|
||||
const fetchGroupFail = (id: string, error: AxiosError) => ({
|
||||
type: GROUP_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchGroups = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchGroupsRequest());
|
||||
|
||||
return api(getState).get('/api/v1/groups')
|
||||
.then(({ data }) => {
|
||||
dispatch(importFetchedGroups(data));
|
||||
dispatch(fetchGroupsSuccess(data));
|
||||
dispatch(fetchGroupRelationships(data.map((item: APIEntity) => item.id)));
|
||||
}).catch(err => dispatch(fetchGroupsFail(err)));
|
||||
};
|
||||
|
||||
const fetchGroupsRequest = () => ({
|
||||
type: GROUPS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchGroupsSuccess = (groups: APIEntity[]) => ({
|
||||
type: GROUPS_FETCH_SUCCESS,
|
||||
groups,
|
||||
});
|
||||
|
||||
const fetchGroupsFail = (error: AxiosError) => ({
|
||||
type: GROUPS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchGroupRelationships = (groupIds: string[]) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const state = getState();
|
||||
const loadedRelationships = state.group_relationships;
|
||||
const newGroupIds = groupIds.filter(id => loadedRelationships.get(id, null) === null);
|
||||
|
||||
if (!state.me || newGroupIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchGroupRelationshipsRequest(newGroupIds));
|
||||
|
||||
return api(getState).get(`/api/v1/groups/relationships?${newGroupIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchGroupRelationshipsSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchGroupRelationshipsFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGroupRelationshipsRequest = (ids: string[]) => ({
|
||||
type: GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
||||
ids,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const fetchGroupRelationshipsSuccess = (relationships: APIEntity[]) => ({
|
||||
type: GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
relationships,
|
||||
skipLoading: true,
|
||||
});
|
||||
|
||||
const fetchGroupRelationshipsFail = (error: AxiosError) => ({
|
||||
type: GROUP_RELATIONSHIPS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const groupKick = (groupId: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupKickRequest(groupId, accountId));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${groupId}/kick`, { account_ids: [accountId] })
|
||||
.then(() => dispatch(groupKickSuccess(groupId, accountId)))
|
||||
.catch(err => dispatch(groupKickFail(groupId, accountId, err)));
|
||||
};
|
||||
|
||||
const groupKickRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_KICK_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupKickSuccess = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_KICK_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupKickFail = (groupId: string, accountId: string, error: AxiosError) => ({
|
||||
type: GROUP_KICK_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchGroupBlocks = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchGroupBlocksRequest(id));
|
||||
|
||||
return api(getState).get(`/api/v1/groups/${id}/blocks`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchGroupBlocksSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchGroupBlocksFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGroupBlocksRequest = (id: string) => ({
|
||||
type: GROUP_BLOCKS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const fetchGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
|
||||
type: GROUP_BLOCKS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchGroupBlocksFail = (id: string, error: AxiosError) => ({
|
||||
type: GROUP_BLOCKS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const expandGroupBlocks = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().user_lists.group_blocks.get(id)?.next || null;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandGroupBlocksRequest(id));
|
||||
|
||||
return api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandGroupBlocksSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandGroupBlocksFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandGroupBlocksRequest = (id: string) => ({
|
||||
type: GROUP_BLOCKS_EXPAND_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const expandGroupBlocksSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
|
||||
type: GROUP_BLOCKS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandGroupBlocksFail = (id: string, error: AxiosError) => ({
|
||||
type: GROUP_BLOCKS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const groupBlock = (groupId: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupBlockRequest(groupId, accountId));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${groupId}/blocks`, { account_ids: [accountId] })
|
||||
.then(() => dispatch(groupBlockSuccess(groupId, accountId)))
|
||||
.catch(err => dispatch(groupBlockFail(groupId, accountId, err)));
|
||||
};
|
||||
|
||||
const groupBlockRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_BLOCK_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupBlockSuccess = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_BLOCK_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupBlockFail = (groupId: string, accountId: string, error: AxiosError) => ({
|
||||
type: GROUP_BLOCK_FAIL,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const groupUnblock = (groupId: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupUnblockRequest(groupId, accountId));
|
||||
|
||||
return api(getState).delete(`/api/v1/groups/${groupId}/blocks?account_ids[]=${accountId}`)
|
||||
.then(() => dispatch(groupUnblockSuccess(groupId, accountId)))
|
||||
.catch(err => dispatch(groupUnblockFail(groupId, accountId, err)));
|
||||
};
|
||||
|
||||
const groupUnblockRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_UNBLOCK_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupUnblockSuccess = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_UNBLOCK_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupUnblockFail = (groupId: string, accountId: string, error: AxiosError) => ({
|
||||
type: GROUP_UNBLOCK_FAIL,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const groupPromoteAccount = (groupId: string, accountId: string, role: GroupRole) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupPromoteAccountRequest(groupId, accountId));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${groupId}/promote`, { account_ids: [accountId], role: role })
|
||||
.then((response) => dispatch(groupPromoteAccountSuccess(groupId, accountId, response.data)))
|
||||
.catch(err => dispatch(groupPromoteAccountFail(groupId, accountId, err)));
|
||||
};
|
||||
|
||||
const groupPromoteAccountRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_PROMOTE_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupPromoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({
|
||||
type: GROUP_PROMOTE_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
memberships,
|
||||
});
|
||||
|
||||
const groupPromoteAccountFail = (groupId: string, accountId: string, error: AxiosError) => ({
|
||||
type: GROUP_PROMOTE_FAIL,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const groupDemoteAccount = (groupId: string, accountId: string, role: GroupRole) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(groupDemoteAccountRequest(groupId, accountId));
|
||||
|
||||
return api(getState).post(`/api/v1/groups/${groupId}/demote`, { account_ids: [accountId], role: role })
|
||||
.then((response) => dispatch(groupDemoteAccountSuccess(groupId, accountId, response.data)))
|
||||
.catch(err => dispatch(groupDemoteAccountFail(groupId, accountId, err)));
|
||||
};
|
||||
|
||||
const groupDemoteAccountRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_DEMOTE_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const groupDemoteAccountSuccess = (groupId: string, accountId: string, memberships: APIEntity[]) => ({
|
||||
type: GROUP_DEMOTE_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
memberships,
|
||||
});
|
||||
|
||||
const groupDemoteAccountFail = (groupId: string, accountId: string, error: AxiosError) => ({
|
||||
type: GROUP_DEMOTE_FAIL,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchGroupMemberships = (id: string, role: GroupRole) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchGroupMembershipsRequest(id, role));
|
||||
|
||||
return api(getState).get(`/api/v1/groups/${id}/memberships`, { params: { role } }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account)));
|
||||
dispatch(fetchGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchGroupMembershipsFail(id, role, error));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGroupMembershipsRequest = (id: string, role: GroupRole) => ({
|
||||
type: GROUP_MEMBERSHIPS_FETCH_REQUEST,
|
||||
id,
|
||||
role,
|
||||
});
|
||||
|
||||
const fetchGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({
|
||||
type: GROUP_MEMBERSHIPS_FETCH_SUCCESS,
|
||||
id,
|
||||
role,
|
||||
memberships,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({
|
||||
type: GROUP_MEMBERSHIPS_FETCH_FAIL,
|
||||
id,
|
||||
role,
|
||||
error,
|
||||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const expandGroupMemberships = (id: string, role: GroupRole) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().group_memberships.get(role).get(id)?.next || null;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandGroupMembershipsRequest(id, role));
|
||||
|
||||
return api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data.map((membership: APIEntity) => membership.account)));
|
||||
dispatch(expandGroupMembershipsSuccess(id, role, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandGroupMembershipsFail(id, role, error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandGroupMembershipsRequest = (id: string, role: GroupRole) => ({
|
||||
type: GROUP_MEMBERSHIPS_EXPAND_REQUEST,
|
||||
id,
|
||||
role,
|
||||
});
|
||||
|
||||
const expandGroupMembershipsSuccess = (id: string, role: GroupRole, memberships: APIEntity[], next: string | null) => ({
|
||||
type: GROUP_MEMBERSHIPS_EXPAND_SUCCESS,
|
||||
id,
|
||||
role,
|
||||
memberships,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandGroupMembershipsFail = (id: string, role: GroupRole, error: AxiosError) => ({
|
||||
type: GROUP_MEMBERSHIPS_EXPAND_FAIL,
|
||||
id,
|
||||
role,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchGroupMembershipRequests = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchGroupMembershipRequestsRequest(id));
|
||||
|
||||
return api(getState).get(`/api/v1/groups/${id}/membership_requests`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(fetchGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchGroupMembershipRequestsFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGroupMembershipRequestsRequest = (id: string) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const fetchGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipNotFound: true,
|
||||
});
|
||||
|
||||
const expandGroupMembershipRequests = (id: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().user_lists.membership_requests.get(id)?.next || null;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandGroupMembershipRequestsRequest(id));
|
||||
|
||||
return api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data));
|
||||
dispatch(expandGroupMembershipRequestsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map((item: APIEntity) => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandGroupMembershipRequestsFail(id, error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandGroupMembershipRequestsRequest = (id: string) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST,
|
||||
id,
|
||||
});
|
||||
|
||||
const expandGroupMembershipRequestsSuccess = (id: string, accounts: APIEntity[], next: string | null) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandGroupMembershipRequestsFail = (id: string, error: AxiosError) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
});
|
||||
|
||||
const authorizeGroupMembershipRequest = (groupId: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(authorizeGroupMembershipRequestRequest(groupId, accountId));
|
||||
|
||||
return api(getState)
|
||||
.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`)
|
||||
.then(() => dispatch(authorizeGroupMembershipRequestSuccess(groupId, accountId)))
|
||||
.catch(error => dispatch(authorizeGroupMembershipRequestFail(groupId, accountId, error)));
|
||||
};
|
||||
|
||||
const authorizeGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const authorizeGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const authorizeGroupMembershipRequestFail = (groupId: string, accountId: string, error: AxiosError) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
const rejectGroupMembershipRequest = (groupId: string, accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(rejectGroupMembershipRequestRequest(groupId, accountId));
|
||||
|
||||
return api(getState)
|
||||
.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`)
|
||||
.then(() => dispatch(rejectGroupMembershipRequestSuccess(groupId, accountId)))
|
||||
.catch(error => dispatch(rejectGroupMembershipRequestFail(groupId, accountId, error)));
|
||||
};
|
||||
|
||||
const rejectGroupMembershipRequestRequest = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const rejectGroupMembershipRequestSuccess = (groupId: string, accountId: string) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||
groupId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const rejectGroupMembershipRequestFail = (groupId: string, accountId: string, error?: AxiosError) => ({
|
||||
type: GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
|
||||
groupId,
|
||||
accountId,
|
||||
error,
|
||||
});
|
||||
|
||||
export {
|
||||
GROUP_CREATE_REQUEST,
|
||||
GROUP_CREATE_SUCCESS,
|
||||
GROUP_CREATE_FAIL,
|
||||
GROUP_UPDATE_REQUEST,
|
||||
GROUP_UPDATE_SUCCESS,
|
||||
GROUP_UPDATE_FAIL,
|
||||
GROUP_DELETE_REQUEST,
|
||||
GROUP_DELETE_SUCCESS,
|
||||
GROUP_DELETE_FAIL,
|
||||
GROUP_FETCH_REQUEST,
|
||||
GROUP_FETCH_SUCCESS,
|
||||
GROUP_FETCH_FAIL,
|
||||
GROUPS_FETCH_REQUEST,
|
||||
GROUPS_FETCH_SUCCESS,
|
||||
GROUPS_FETCH_FAIL,
|
||||
GROUP_RELATIONSHIPS_FETCH_REQUEST,
|
||||
GROUP_RELATIONSHIPS_FETCH_SUCCESS,
|
||||
GROUP_RELATIONSHIPS_FETCH_FAIL,
|
||||
GROUP_KICK_REQUEST,
|
||||
GROUP_KICK_SUCCESS,
|
||||
GROUP_KICK_FAIL,
|
||||
GROUP_BLOCKS_FETCH_REQUEST,
|
||||
GROUP_BLOCKS_FETCH_SUCCESS,
|
||||
GROUP_BLOCKS_FETCH_FAIL,
|
||||
GROUP_BLOCKS_EXPAND_REQUEST,
|
||||
GROUP_BLOCKS_EXPAND_SUCCESS,
|
||||
GROUP_BLOCKS_EXPAND_FAIL,
|
||||
GROUP_BLOCK_REQUEST,
|
||||
GROUP_BLOCK_SUCCESS,
|
||||
GROUP_BLOCK_FAIL,
|
||||
GROUP_UNBLOCK_REQUEST,
|
||||
GROUP_UNBLOCK_SUCCESS,
|
||||
GROUP_UNBLOCK_FAIL,
|
||||
GROUP_PROMOTE_REQUEST,
|
||||
GROUP_PROMOTE_SUCCESS,
|
||||
GROUP_PROMOTE_FAIL,
|
||||
GROUP_DEMOTE_REQUEST,
|
||||
GROUP_DEMOTE_SUCCESS,
|
||||
GROUP_DEMOTE_FAIL,
|
||||
GROUP_MEMBERSHIPS_FETCH_REQUEST,
|
||||
GROUP_MEMBERSHIPS_FETCH_SUCCESS,
|
||||
GROUP_MEMBERSHIPS_FETCH_FAIL,
|
||||
GROUP_MEMBERSHIPS_EXPAND_REQUEST,
|
||||
GROUP_MEMBERSHIPS_EXPAND_SUCCESS,
|
||||
GROUP_MEMBERSHIPS_EXPAND_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUESTS_FETCH_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUESTS_EXPAND_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUEST_AUTHORIZE_FAIL,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_REQUEST,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_SUCCESS,
|
||||
GROUP_MEMBERSHIP_REQUEST_REJECT_FAIL,
|
||||
deleteGroup,
|
||||
deleteGroupRequest,
|
||||
deleteGroupSuccess,
|
||||
deleteGroupFail,
|
||||
fetchGroup,
|
||||
fetchGroupRequest,
|
||||
fetchGroupSuccess,
|
||||
fetchGroupFail,
|
||||
fetchGroups,
|
||||
fetchGroupsRequest,
|
||||
fetchGroupsSuccess,
|
||||
fetchGroupsFail,
|
||||
fetchGroupRelationships,
|
||||
fetchGroupRelationshipsRequest,
|
||||
fetchGroupRelationshipsSuccess,
|
||||
fetchGroupRelationshipsFail,
|
||||
groupKick,
|
||||
groupKickRequest,
|
||||
groupKickSuccess,
|
||||
groupKickFail,
|
||||
fetchGroupBlocks,
|
||||
fetchGroupBlocksRequest,
|
||||
fetchGroupBlocksSuccess,
|
||||
fetchGroupBlocksFail,
|
||||
expandGroupBlocks,
|
||||
expandGroupBlocksRequest,
|
||||
expandGroupBlocksSuccess,
|
||||
expandGroupBlocksFail,
|
||||
groupBlock,
|
||||
groupBlockRequest,
|
||||
groupBlockSuccess,
|
||||
groupBlockFail,
|
||||
groupUnblock,
|
||||
groupUnblockRequest,
|
||||
groupUnblockSuccess,
|
||||
groupUnblockFail,
|
||||
groupPromoteAccount,
|
||||
groupPromoteAccountRequest,
|
||||
groupPromoteAccountSuccess,
|
||||
groupPromoteAccountFail,
|
||||
groupDemoteAccount,
|
||||
groupDemoteAccountRequest,
|
||||
groupDemoteAccountSuccess,
|
||||
groupDemoteAccountFail,
|
||||
fetchGroupMemberships,
|
||||
fetchGroupMembershipsRequest,
|
||||
fetchGroupMembershipsSuccess,
|
||||
fetchGroupMembershipsFail,
|
||||
expandGroupMemberships,
|
||||
expandGroupMembershipsRequest,
|
||||
expandGroupMembershipsSuccess,
|
||||
expandGroupMembershipsFail,
|
||||
fetchGroupMembershipRequests,
|
||||
fetchGroupMembershipRequestsRequest,
|
||||
fetchGroupMembershipRequestsSuccess,
|
||||
fetchGroupMembershipRequestsFail,
|
||||
expandGroupMembershipRequests,
|
||||
expandGroupMembershipRequestsRequest,
|
||||
expandGroupMembershipRequestsSuccess,
|
||||
expandGroupMembershipRequestsFail,
|
||||
authorizeGroupMembershipRequest,
|
||||
authorizeGroupMembershipRequestRequest,
|
||||
authorizeGroupMembershipRequestSuccess,
|
||||
authorizeGroupMembershipRequestFail,
|
||||
rejectGroupMembershipRequest,
|
||||
rejectGroupMembershipRequestRequest,
|
||||
rejectGroupMembershipRequestSuccess,
|
||||
rejectGroupMembershipRequestFail,
|
||||
};
|
|
@ -1,201 +0,0 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||
import type { APIEntity } from 'soapbox/types/entities';
|
||||
|
||||
const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
|
||||
const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
|
||||
const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL';
|
||||
|
||||
const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
|
||||
const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
|
||||
const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL';
|
||||
|
||||
const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST';
|
||||
const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS';
|
||||
const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_FETCH_FAIL = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
|
||||
|
||||
const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
|
||||
const FOLLOWED_HASHTAGS_EXPAND_FAIL = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
|
||||
|
||||
const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchHashtagRequest());
|
||||
|
||||
api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => {
|
||||
dispatch(fetchHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(fetchHashtagFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchHashtagRequest = () => ({
|
||||
type: HASHTAG_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FETCH_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const fetchHashtagFail = (error: AxiosError) => ({
|
||||
type: HASHTAG_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(followHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => {
|
||||
dispatch(followHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(followHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const followHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_FOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const followHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_FOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const followHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_FOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(unfollowHashtagRequest(name));
|
||||
|
||||
api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => {
|
||||
dispatch(unfollowHashtagSuccess(name, data));
|
||||
}).catch(err => {
|
||||
dispatch(unfollowHashtagFail(name, err));
|
||||
});
|
||||
};
|
||||
|
||||
const unfollowHashtagRequest = (name: string) => ({
|
||||
type: HASHTAG_UNFOLLOW_REQUEST,
|
||||
name,
|
||||
});
|
||||
|
||||
const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({
|
||||
type: HASHTAG_UNFOLLOW_SUCCESS,
|
||||
name,
|
||||
tag,
|
||||
});
|
||||
|
||||
const unfollowHashtagFail = (name: string, error: AxiosError) => ({
|
||||
type: HASHTAG_UNFOLLOW_FAIL,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch(fetchFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get('/api/v1/followed_tags').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchFollowedHashtagsFail(err));
|
||||
});
|
||||
};
|
||||
|
||||
const fetchFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const fetchFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
const expandFollowedHashtags = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const url = getState().followed_tags.next;
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowedHashtagsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowedHashtagsFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
const expandFollowedHashtagsRequest = () => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsSuccess = (followed_tags: APIEntity[], next: string | null) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
followed_tags,
|
||||
next,
|
||||
});
|
||||
|
||||
const expandFollowedHashtagsFail = (error: AxiosError) => ({
|
||||
type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
|
||||
export {
|
||||
HASHTAG_FETCH_REQUEST,
|
||||
HASHTAG_FETCH_SUCCESS,
|
||||
HASHTAG_FETCH_FAIL,
|
||||
HASHTAG_FOLLOW_REQUEST,
|
||||
HASHTAG_FOLLOW_SUCCESS,
|
||||
HASHTAG_FOLLOW_FAIL,
|
||||
HASHTAG_UNFOLLOW_REQUEST,
|
||||
HASHTAG_UNFOLLOW_SUCCESS,
|
||||
HASHTAG_UNFOLLOW_FAIL,
|
||||
FOLLOWED_HASHTAGS_FETCH_REQUEST,
|
||||
FOLLOWED_HASHTAGS_FETCH_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_FETCH_FAIL,
|
||||
FOLLOWED_HASHTAGS_EXPAND_REQUEST,
|
||||
FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
|
||||
FOLLOWED_HASHTAGS_EXPAND_FAIL,
|
||||
fetchHashtag,
|
||||
fetchHashtagRequest,
|
||||
fetchHashtagSuccess,
|
||||
fetchHashtagFail,
|
||||
followHashtag,
|
||||
followHashtagRequest,
|
||||
followHashtagSuccess,
|
||||
followHashtagFail,
|
||||
unfollowHashtag,
|
||||
unfollowHashtagRequest,
|
||||
unfollowHashtagSuccess,
|
||||
unfollowHashtagFail,
|
||||
fetchFollowedHashtags,
|
||||
fetchFollowedHashtagsRequest,
|
||||
fetchFollowedHashtagsSuccess,
|
||||
fetchFollowedHashtagsFail,
|
||||
expandFollowedHashtags,
|
||||
expandFollowedHashtagsRequest,
|
||||
expandFollowedHashtagsSuccess,
|
||||
expandFollowedHashtagsFail,
|
||||
};
|
|
@ -1,26 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { type Account, accountSchema } from 'soapbox/schemas';
|
||||
|
||||
|
||||
import { useRelationships } from './useRelationships';
|
||||
|
||||
function useAccount(id: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: account, ...result } = useEntity<Account>(
|
||||
[Entities.ACCOUNTS, id],
|
||||
() => api.get(`/api/v1/accounts/${id}`),
|
||||
{ schema: accountSchema },
|
||||
);
|
||||
const { relationships, isLoading } = useRelationships([account?.id as string]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
isLoading: result.isLoading || isLoading,
|
||||
account: account ? { ...account, relationship: relationships[0] || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useAccount };
|
|
@ -1,21 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { type Relationship, relationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useRelationships(ids: string[]) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities: relationships, ...result } = useEntities<Relationship>(
|
||||
[Entities.RELATIONSHIPS],
|
||||
() => api.get(`/api/v1/accounts/relationships?${ids.map(id => `id[]=${id}`).join('&')}`),
|
||||
{ schema: relationshipSchema, enabled: ids.filter(Boolean).length > 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
relationships,
|
||||
};
|
||||
}
|
||||
|
||||
export { useRelationships };
|
|
@ -1,41 +0,0 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroup } from '../useGroup';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
|
||||
describe('useGroup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${group.id}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroup(group.id));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.group).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,41 +0,0 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupLookup } from '../useGroupLookup';
|
||||
|
||||
const group = buildGroup({ id: '1', slug: 'soapbox' });
|
||||
|
||||
describe('useGroupLookup hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity?.id).toBe(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupLookup(group.slug));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,44 +0,0 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildStatus } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { useGroupMedia } from '../useGroupMedia';
|
||||
|
||||
const status = buildStatus();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMedia hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(1);
|
||||
expect(result.current.entities[0].id).toBe(status.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMedia(groupId));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.entities.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,45 +0,0 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroupMember } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupMembers } from '../useGroupMembers';
|
||||
|
||||
const groupMember = buildGroupMember();
|
||||
const groupId = '1';
|
||||
|
||||
describe('useGroupMembers hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(1);
|
||||
expect(result.current.groupMembers[0].id).toBe(groupMember.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN));
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groupMembers.length).toBe(0);
|
||||
expect(result.current.isError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,47 +0,0 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { buildGroup } from 'soapbox/jest/factory';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroups } from '../useGroups';
|
||||
|
||||
const group = buildGroup({ id: '1', display_name: 'soapbox' });
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
||||
describe('useGroups hook', () => {
|
||||
describe('with a successful request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [group]);
|
||||
});
|
||||
});
|
||||
|
||||
it('is successful', async () => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful query', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('is has error state', async() => {
|
||||
const { result } = renderHook(useGroups, undefined, store);
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
expect(result.current.groups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,15 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
|
||||
import type { Group, GroupMember } from 'soapbox/schemas';
|
||||
|
||||
function useBlockGroupMember(group: Group, groupMember: GroupMember) {
|
||||
const { createEntity } = useEntityActions<GroupMember>(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
|
||||
{ post: `/api/v1/groups/${group.id}/blocks` },
|
||||
);
|
||||
|
||||
return createEntity;
|
||||
}
|
||||
|
||||
export { useBlockGroupMember };
|
|
@ -1,22 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useCancelMembershipRequest(group: Group) {
|
||||
const api = useApi();
|
||||
const me = useOwnAccount();
|
||||
|
||||
const { createEntity, isSubmitting } = useCreateEntity(
|
||||
[Entities.GROUP_RELATIONSHIPS],
|
||||
() => api.post(`/api/v1/groups/${group.id}/membership_requests/${me?.id}/reject`),
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: createEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCancelMembershipRequest };
|
|
@ -1,33 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
interface CreateGroupParams {
|
||||
display_name?: string
|
||||
note?: string
|
||||
avatar?: File
|
||||
header?: File
|
||||
group_visibility?: 'members_only' | 'everyone'
|
||||
discoverable?: boolean
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
function useCreateGroup() {
|
||||
const api = useApi();
|
||||
|
||||
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS, 'search', ''], (params: CreateGroupParams) => {
|
||||
return api.post('/api/v1/groups', params, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}, { schema: groupSchema });
|
||||
|
||||
return {
|
||||
createGroup: createEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCreateGroup, type CreateGroupParams };
|
|
@ -1,18 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useDeleteGroup() {
|
||||
const { deleteEntity, isSubmitting } = useEntityActions<Group>(
|
||||
[Entities.GROUPS],
|
||||
{ delete: '/api/v1/groups/:id' },
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: deleteEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDeleteGroup };
|
|
@ -1,20 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useDeleteEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useDeleteGroupStatus(group: Group, statusId: string) {
|
||||
const api = useApi();
|
||||
const { deleteEntity, isSubmitting } = useDeleteEntity(
|
||||
Entities.STATUSES,
|
||||
() => api.delete(`/api/v1/groups/${group.id}/statuses/${statusId}`),
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: deleteEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDeleteGroupStatus };
|
|
@ -1,19 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
import { groupMemberSchema } from 'soapbox/schemas';
|
||||
|
||||
import type { Group, GroupMember } from 'soapbox/schemas';
|
||||
|
||||
function useDemoteGroupMember(group: Group, groupMember: GroupMember) {
|
||||
const { createEntity } = useEntityActions<GroupMember>(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
|
||||
{ post: `/api/v1/groups/${group.id}/demote` },
|
||||
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
|
||||
);
|
||||
|
||||
return createEntity;
|
||||
}
|
||||
|
||||
export { useDemoteGroupMember };
|
|
@ -1,24 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { type Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
function useGroup(groupId: string, refetch = true) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: group, ...result } = useEntity<Group>(
|
||||
[Entities.GROUPS, groupId],
|
||||
() => api.get(`/api/v1/groups/${groupId}`),
|
||||
{ schema: groupSchema, refetch },
|
||||
);
|
||||
const { entity: relationship } = useGroupRelationship(groupId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
group: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroup };
|
|
@ -1,26 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityLookup } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
function useGroupLookup(slug: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: group, ...result } = useEntityLookup(
|
||||
Entities.GROUPS,
|
||||
(group) => group.slug === slug,
|
||||
() => api.get(`/api/v1/groups/lookup?name=${slug}`),
|
||||
{ schema: groupSchema, enabled: !!slug },
|
||||
);
|
||||
|
||||
const { entity: relationship } = useGroupRelationship(group?.id);
|
||||
|
||||
return {
|
||||
...result,
|
||||
entity: group ? { ...group, relationship: relationship || null } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupLookup };
|
|
@ -1,17 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { normalizeStatus } from 'soapbox/normalizers';
|
||||
import { toSchema } from 'soapbox/utils/normalizers';
|
||||
|
||||
const statusSchema = toSchema(normalizeStatus);
|
||||
|
||||
function useGroupMedia(groupId: string) {
|
||||
const api = useApi();
|
||||
|
||||
return useEntities([Entities.STATUSES, 'groupMedia', groupId], () => {
|
||||
return api.get(`/api/v1/timelines/group/${groupId}?only_media=true`);
|
||||
}, { schema: statusSchema });
|
||||
}
|
||||
|
||||
export { useGroupMedia };
|
|
@ -1,23 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useApi } from '../../../hooks/useApi';
|
||||
|
||||
function useGroupMembers(groupId: string, role: GroupRoles) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupMember>(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupId, role],
|
||||
() => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`),
|
||||
{ schema: groupMemberSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
groupMembers: entities,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupMembers };
|
|
@ -1,47 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useDismissEntity, useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { accountSchema } from 'soapbox/schemas';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { useGroupRelationship } from './useGroupRelationship';
|
||||
|
||||
import type { ExpandedEntitiesPath } from 'soapbox/entity-store/hooks/types';
|
||||
|
||||
function useGroupMembershipRequests(groupId: string) {
|
||||
const api = useApi();
|
||||
const path: ExpandedEntitiesPath = [Entities.ACCOUNTS, 'membership_requests', groupId];
|
||||
|
||||
const { entity: relationship } = useGroupRelationship(groupId);
|
||||
|
||||
const { entities, invalidate, fetchEntities, ...rest } = useEntities(
|
||||
path,
|
||||
() => api.get(`/api/v1/groups/${groupId}/membership_requests`),
|
||||
{
|
||||
schema: accountSchema,
|
||||
enabled: relationship?.role === GroupRoles.OWNER || relationship?.role === GroupRoles.ADMIN,
|
||||
},
|
||||
);
|
||||
|
||||
const { dismissEntity: authorize } = useDismissEntity(path, async (accountId: string) => {
|
||||
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
|
||||
invalidate();
|
||||
return response;
|
||||
});
|
||||
|
||||
const { dismissEntity: reject } = useDismissEntity(path, async (accountId: string) => {
|
||||
const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
|
||||
invalidate();
|
||||
return response;
|
||||
});
|
||||
|
||||
return {
|
||||
accounts: entities,
|
||||
refetch: fetchEntities,
|
||||
authorize,
|
||||
reject,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupMembershipRequests };
|
|
@ -1,35 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { fetchGroupRelationshipsSuccess } from 'soapbox/actions/groups';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupRelationship(groupId: string | undefined) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { entity: groupRelationship, ...result } = useEntity<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, groupId as string],
|
||||
() => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
|
||||
{
|
||||
enabled: !!groupId,
|
||||
schema: z.array(groupRelationshipSchema).transform(arr => arr[0]),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupRelationship?.id) {
|
||||
dispatch(fetchGroupRelationshipsSuccess([groupRelationship]));
|
||||
}
|
||||
}, [groupRelationship?.id]);
|
||||
|
||||
return {
|
||||
entity: groupRelationship,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupRelationship };
|
|
@ -1,27 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { type GroupRelationship, groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupRelationships(groupIds: string[]) {
|
||||
const api = useApi();
|
||||
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||
|
||||
const { entities, ...result } = useEntities<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, ...groupIds],
|
||||
() => api.get(`/api/v1/groups/relationships?${q}`),
|
||||
{ schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
|
||||
);
|
||||
|
||||
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
||||
map[relationship.id] = relationship;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...result,
|
||||
relationships,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupRelationships };
|
|
@ -1,37 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useGroupSearch(search: string) {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'discover', 'search', search],
|
||||
() => api.get('/api/v1/groups/search', {
|
||||
params: {
|
||||
q: search,
|
||||
},
|
||||
}),
|
||||
{ enabled: features.groupsDiscovery && !!search, schema: groupSchema },
|
||||
);
|
||||
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupSearch };
|
|
@ -1,21 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
|
||||
|
||||
function useGroupTag(tagId: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entity: tag, ...result } = useEntity<GroupTag>(
|
||||
[Entities.GROUP_TAGS, tagId],
|
||||
() => api.get(`/api/v1/tags/${tagId }`),
|
||||
{ schema: groupTagSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupTag };
|
|
@ -1,23 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupTagSchema } from 'soapbox/schemas';
|
||||
|
||||
import type { GroupTag } from 'soapbox/schemas';
|
||||
|
||||
function useGroupTags(groupId: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupTag>(
|
||||
[Entities.GROUP_TAGS, groupId],
|
||||
() => api.get(`/api/v1/truth/trends/groups/${groupId}/tags`),
|
||||
{ schema: groupTagSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
tags: entities,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupTags };
|
|
@ -1,47 +0,0 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { useFeatures } from 'soapbox/hooks/useFeatures';
|
||||
|
||||
type Validation = {
|
||||
error: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const ValidationKeys = {
|
||||
validation: (name: string) => ['group', 'validation', name] as const,
|
||||
};
|
||||
|
||||
function useGroupValidation(name: string = '') {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const getValidation = async() => {
|
||||
const { data } = await api.get<Validation>('/api/v1/groups/validate', {
|
||||
params: { name },
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.status === 422) {
|
||||
return { data: error.response.data };
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const queryInfo = useQuery<Validation>(ValidationKeys.validation(name), getValidation, {
|
||||
enabled: features.groupsValidation && !!name,
|
||||
});
|
||||
|
||||
return {
|
||||
...queryInfo,
|
||||
data: {
|
||||
...queryInfo.data,
|
||||
isValid: !queryInfo.data?.error ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupValidation };
|
|
@ -1,31 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
import { useFeatures } from 'soapbox/hooks/useFeatures';
|
||||
import { groupSchema, type Group } from 'soapbox/schemas/group';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
function useGroups(q: string = '') {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'search', q],
|
||||
() => api.get('/api/v1/groups', { params: { q } }),
|
||||
{ enabled: features.groups, schema: groupSchema },
|
||||
);
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroups };
|
|
@ -1,35 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
function useGroupsFromTag(tagId: string) {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'tags', tagId],
|
||||
() => api.get(`/api/v1/tags/${tagId}/groups`),
|
||||
{
|
||||
schema: groupSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useGroupsFromTag };
|
|
@ -1,25 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
import { groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroups } from './useGroups';
|
||||
|
||||
import type { Group, GroupRelationship } from 'soapbox/schemas';
|
||||
|
||||
function useJoinGroup(group: Group) {
|
||||
const { invalidate } = useGroups();
|
||||
|
||||
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, group.id],
|
||||
{ post: `/api/v1/groups/${group.id}/join` },
|
||||
{ schema: groupRelationshipSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: createEntity,
|
||||
isSubmitting,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export { useJoinGroup };
|
|
@ -1,25 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
import { groupRelationshipSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroups } from './useGroups';
|
||||
|
||||
import type { Group, GroupRelationship } from 'soapbox/schemas';
|
||||
|
||||
function useLeaveGroup(group: Group) {
|
||||
const { invalidate } = useGroups();
|
||||
|
||||
const { createEntity, isSubmitting } = useEntityActions<GroupRelationship>(
|
||||
[Entities.GROUP_RELATIONSHIPS, group.id],
|
||||
{ post: `/api/v1/groups/${group.id}/leave` },
|
||||
{ schema: groupRelationshipSchema },
|
||||
);
|
||||
|
||||
return {
|
||||
mutate: createEntity,
|
||||
isSubmitting,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export { useLeaveGroup };
|
|
@ -1,36 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useApi } from '../../../hooks/useApi';
|
||||
import { useFeatures } from '../../../hooks/useFeatures';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
function usePopularGroups() {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'popular'],
|
||||
() => api.get('/api/v1/truth/trends/groups'),
|
||||
{
|
||||
schema: groupSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { usePopularGroups };
|
|
@ -1,25 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { type GroupTag, groupTagSchema } from 'soapbox/schemas';
|
||||
|
||||
function usePopularTags() {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<GroupTag>(
|
||||
[Entities.GROUP_TAGS],
|
||||
() => api.get('/api/v1/groups/tags'),
|
||||
{
|
||||
schema: groupTagSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
tags: entities,
|
||||
};
|
||||
}
|
||||
|
||||
export { usePopularTags };
|
|
@ -1,19 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
import { groupMemberSchema } from 'soapbox/schemas';
|
||||
|
||||
import type { Group, GroupMember } from 'soapbox/schemas';
|
||||
|
||||
function usePromoteGroupMember(group: Group, groupMember: GroupMember) {
|
||||
const { createEntity } = useEntityActions<GroupMember>(
|
||||
[Entities.GROUP_MEMBERSHIPS, groupMember.id],
|
||||
{ post: `/api/v1/groups/${group.id}/promote` },
|
||||
{ schema: z.array(groupMemberSchema).transform((arr) => arr[0]) },
|
||||
);
|
||||
|
||||
return createEntity;
|
||||
}
|
||||
|
||||
export { usePromoteGroupMember };
|
|
@ -1,34 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { type Group, groupSchema } from 'soapbox/schemas';
|
||||
|
||||
import { useGroupRelationships } from './useGroupRelationships';
|
||||
|
||||
function useSuggestedGroups() {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...result } = useEntities<Group>(
|
||||
[Entities.GROUPS, 'suggested'],
|
||||
() => api.get('/api/v1/truth/suggestions/groups'),
|
||||
{
|
||||
schema: groupSchema,
|
||||
enabled: features.groupsDiscovery,
|
||||
},
|
||||
);
|
||||
|
||||
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||
|
||||
const groups = entities.map((group) => ({
|
||||
...group,
|
||||
relationship: relationships[group.id] || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...result,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
export { useSuggestedGroups };
|
|
@ -1,33 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useCreateEntity } from 'soapbox/entity-store/hooks';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { groupSchema } from 'soapbox/schemas';
|
||||
|
||||
interface UpdateGroupParams {
|
||||
display_name?: string
|
||||
note?: string
|
||||
avatar?: File
|
||||
header?: File
|
||||
group_visibility?: string
|
||||
discoverable?: boolean
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
function useUpdateGroup(groupId: string) {
|
||||
const api = useApi();
|
||||
|
||||
const { createEntity, ...rest } = useCreateEntity([Entities.GROUPS], (params: UpdateGroupParams) => {
|
||||
return api.put(`/api/v1/groups/${groupId}`, params, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}, { schema: groupSchema });
|
||||
|
||||
return {
|
||||
updateGroup: createEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useUpdateGroup };
|
|
@ -1,18 +0,0 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntityActions } from 'soapbox/entity-store/hooks';
|
||||
|
||||
import type { GroupTag } from 'soapbox/schemas';
|
||||
|
||||
function useUpdateGroupTag(groupId: string, tagId: string) {
|
||||
const { updateEntity, ...rest } = useEntityActions<GroupTag>(
|
||||
[Entities.GROUP_TAGS, groupId, tagId],
|
||||
{ patch: `/api/v1/groups/${groupId}/tags/${tagId}` },
|
||||
);
|
||||
|
||||
return {
|
||||
updateGroupTag: updateEntity,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export { useUpdateGroupTag };
|
|
@ -1,40 +0,0 @@
|
|||
|
||||
/**
|
||||
* Accounts
|
||||
*/
|
||||
export { useAccount } from './accounts/useAccount';
|
||||
|
||||
/**
|
||||
* Groups
|
||||
*/
|
||||
export { useBlockGroupMember } from './groups/useBlockGroupMember';
|
||||
export { useCancelMembershipRequest } from './groups/useCancelMembershipRequest';
|
||||
export { useCreateGroup, type CreateGroupParams } from './groups/useCreateGroup';
|
||||
export { useDeleteGroup } from './groups/useDeleteGroup';
|
||||
export { useDemoteGroupMember } from './groups/useDemoteGroupMember';
|
||||
export { useGroup } from './groups/useGroup';
|
||||
export { useGroupLookup } from './groups/useGroupLookup';
|
||||
export { useGroupMedia } from './groups/useGroupMedia';
|
||||
export { useGroupMembers } from './groups/useGroupMembers';
|
||||
export { useGroupMembershipRequests } from './groups/useGroupMembershipRequests';
|
||||
export { useGroupRelationship } from './groups/useGroupRelationship';
|
||||
export { useGroupRelationships } from './groups/useGroupRelationships';
|
||||
export { useGroupSearch } from './groups/useGroupSearch';
|
||||
export { useGroupTag } from './groups/useGroupTag';
|
||||
export { useGroupTags } from './groups/useGroupTags';
|
||||
export { useGroupValidation } from './groups/useGroupValidation';
|
||||
export { useGroups } from './groups/useGroups';
|
||||
export { useGroupsFromTag } from './groups/useGroupsFromTag';
|
||||
export { useJoinGroup } from './groups/useJoinGroup';
|
||||
export { useLeaveGroup } from './groups/useLeaveGroup';
|
||||
export { usePopularGroups } from './groups/usePopularGroups';
|
||||
export { usePopularTags } from './groups/usePopularTags';
|
||||
export { usePromoteGroupMember } from './groups/usePromoteGroupMember';
|
||||
export { useSuggestedGroups } from './groups/useSuggestedGroups';
|
||||
export { useUpdateGroup } from './groups/useUpdateGroup';
|
||||
export { useUpdateGroupTag } from './groups/useUpdateGroupTag';
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
export { useRelationships } from './accounts/useRelationships';
|
|
@ -1,180 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { HStack, IconButton, Text } from 'soapbox/components/ui';
|
||||
|
||||
interface IAuthorizeRejectButtons {
|
||||
onAuthorize(): Promise<unknown> | unknown
|
||||
onReject(): Promise<unknown> | unknown
|
||||
countdown?: number
|
||||
}
|
||||
|
||||
/** Buttons to approve or reject a pending item, usually an account. */
|
||||
const AuthorizeRejectButtons: React.FC<IAuthorizeRejectButtons> = ({ onAuthorize, onReject, countdown }) => {
|
||||
const [state, setState] = useState<'authorizing' | 'rejecting' | 'authorized' | 'rejected' | 'pending'>('pending');
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const interval = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
|
||||
const startProgressInterval = () => {
|
||||
let startValue = 1;
|
||||
interval.current = setInterval(() => {
|
||||
startValue++;
|
||||
const newValue = startValue * 3.6; // get to 360 (deg)
|
||||
setProgress(newValue);
|
||||
|
||||
if (newValue >= 360) {
|
||||
clearInterval(interval.current as NodeJS.Timeout);
|
||||
setProgress(0);
|
||||
}
|
||||
}, (countdown as number) / 100);
|
||||
};
|
||||
|
||||
function handleAction(
|
||||
present: 'authorizing' | 'rejecting',
|
||||
past: 'authorized' | 'rejected',
|
||||
action: () => Promise<unknown> | unknown,
|
||||
): void {
|
||||
if (state === present) {
|
||||
if (interval.current) {
|
||||
clearInterval(interval.current);
|
||||
}
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
setState('pending');
|
||||
} else {
|
||||
const doAction = async () => {
|
||||
try {
|
||||
await action();
|
||||
setState(past);
|
||||
} catch (e) {
|
||||
if (e) console.error(e);
|
||||
}
|
||||
};
|
||||
if (typeof countdown === 'number') {
|
||||
setState(present);
|
||||
timeout.current = setTimeout(doAction, countdown);
|
||||
startProgressInterval();
|
||||
} else {
|
||||
doAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthorize = async () => handleAction('authorizing', 'authorized', onAuthorize);
|
||||
const handleReject = async () => handleAction('rejecting', 'rejected', onReject);
|
||||
|
||||
const renderStyle = (selectedState: typeof state) => {
|
||||
if (state === 'authorizing' && selectedState === 'authorizing') {
|
||||
return {
|
||||
background: `conic-gradient(rgb(var(--color-primary-500)) ${progress}deg, rgb(var(--color-primary-500) / 0.1) 0deg)`,
|
||||
};
|
||||
} else if (state === 'rejecting' && selectedState === 'rejecting') {
|
||||
return {
|
||||
background: `conic-gradient(rgb(var(--color-danger-600)) ${progress}deg, rgb(var(--color-danger-600) / 0.1) 0deg)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
if (interval.current) {
|
||||
clearInterval(interval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
switch (state) {
|
||||
case 'authorized':
|
||||
return (
|
||||
<ActionEmblem text={<FormattedMessage id='authorize.success' defaultMessage='Approved' />} />
|
||||
);
|
||||
case 'rejected':
|
||||
return (
|
||||
<ActionEmblem text={<FormattedMessage id='reject.success' defaultMessage='Rejected' />} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<HStack space={3} alignItems='center'>
|
||||
<AuthorizeRejectButton
|
||||
theme='danger'
|
||||
icon={require('@tabler/icons/x.svg')}
|
||||
action={handleReject}
|
||||
isLoading={state === 'rejecting'}
|
||||
disabled={state === 'authorizing'}
|
||||
style={renderStyle('rejecting')}
|
||||
/>
|
||||
<AuthorizeRejectButton
|
||||
theme='primary'
|
||||
icon={require('@tabler/icons/check.svg')}
|
||||
action={handleAuthorize}
|
||||
isLoading={state === 'authorizing'}
|
||||
disabled={state === 'rejecting'}
|
||||
style={renderStyle('authorizing')}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface IActionEmblem {
|
||||
text: React.ReactNode
|
||||
}
|
||||
|
||||
const ActionEmblem: React.FC<IActionEmblem> = ({ text }) => {
|
||||
return (
|
||||
<div className='rounded-full bg-gray-100 px-4 py-2 dark:bg-gray-800'>
|
||||
<Text theme='muted' size='sm'>
|
||||
{text}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAuthorizeRejectButton {
|
||||
theme: 'primary' | 'danger'
|
||||
icon: string
|
||||
action(): void
|
||||
isLoading?: boolean
|
||||
disabled?: boolean
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
const AuthorizeRejectButton: React.FC<IAuthorizeRejectButton> = ({ theme, icon, action, isLoading, style, disabled }) => {
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div
|
||||
style={style}
|
||||
className={
|
||||
clsx({
|
||||
'flex h-11 w-11 items-center justify-center rounded-full': true,
|
||||
'bg-danger-600/10': theme === 'danger',
|
||||
'bg-primary-500/10': theme === 'primary',
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
src={isLoading ? require('@tabler/icons/player-stop-filled.svg') : icon}
|
||||
onClick={action}
|
||||
theme='seamless'
|
||||
className='h-10 w-10 items-center justify-center bg-white focus:!ring-0 dark:!bg-gray-900'
|
||||
iconClassName={clsx('h-6 w-6', {
|
||||
'text-primary-500': theme === 'primary',
|
||||
'text-danger-600': theme === 'danger',
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AuthorizeRejectButtons };
|
|
@ -1,109 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Counter, Icon } from '../ui';
|
||||
|
||||
export interface MenuItem {
|
||||
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>
|
||||
active?: boolean
|
||||
count?: number
|
||||
destructive?: boolean
|
||||
href?: string
|
||||
icon?: string
|
||||
meta?: string
|
||||
middleClick?(event: React.MouseEvent): void
|
||||
target?: React.HTMLAttributeAnchorTarget
|
||||
text: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface IDropdownMenuItem {
|
||||
index: number
|
||||
item: MenuItem | null
|
||||
onClick?(): void
|
||||
}
|
||||
|
||||
const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => {
|
||||
const history = useHistory();
|
||||
|
||||
const itemRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!item) return;
|
||||
if (onClick) onClick();
|
||||
|
||||
|
||||
if (item.to) {
|
||||
event.preventDefault();
|
||||
history.push(item.to);
|
||||
} else if (typeof item.action === 'function') {
|
||||
event.preventDefault();
|
||||
item.action(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuxClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
if (!item) return;
|
||||
if (onClick) onClick();
|
||||
|
||||
if (event.button === 1 && item.middleClick) {
|
||||
item.middleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handleClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const firstItem = index === 0;
|
||||
|
||||
if (itemRef.current && firstItem) {
|
||||
itemRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, [itemRef.current, index]);
|
||||
|
||||
if (item === null) {
|
||||
return <li className='mx-2 my-1 h-[2px] bg-gray-100 dark:bg-gray-800' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className='truncate focus-visible:ring-2 focus-visible:ring-primary-500'>
|
||||
<a
|
||||
href={item.href || item.to || '#'}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
ref={itemRef}
|
||||
data-index={index}
|
||||
onClick={handleClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
onKeyPress={handleItemKeyPress}
|
||||
target={item.target}
|
||||
title={item.text}
|
||||
className={
|
||||
clsx({
|
||||
'flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 focus:outline-none cursor-pointer': true,
|
||||
'text-danger-600 dark:text-danger-400': item.destructive,
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.icon && <Icon src={item.icon} className='mr-3 h-5 w-5 flex-none rtl:ml-3 rtl:mr-0' />}
|
||||
|
||||
<span className='truncate font-medium'>{item.text}</span>
|
||||
|
||||
{item.count ? (
|
||||
<span className='ml-auto h-5 w-5 flex-none'>
|
||||
<Counter count={item.count} />
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItem;
|
|
@ -1,332 +0,0 @@
|
|||
import { offset, Placement, useFloating, flip, arrow, shift } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { closeDropdownMenu as closeDropdownMenuRedux, openDropdownMenu } from 'soapbox/actions/dropdown-menu';
|
||||
import { closeModal, openModal } from 'soapbox/actions/modals';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
|
||||
import { IconButton, Portal } from '../ui';
|
||||
|
||||
import DropdownMenuItem, { MenuItem } from './dropdown-menu-item';
|
||||
|
||||
import type { Status } from 'soapbox/types/entities';
|
||||
|
||||
export type Menu = Array<MenuItem | null>;
|
||||
|
||||
interface IDropdownMenu {
|
||||
children?: React.ReactElement
|
||||
disabled?: boolean
|
||||
items: Menu
|
||||
onClose?: () => void
|
||||
onOpen?: () => void
|
||||
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>
|
||||
placement?: Placement
|
||||
src?: string
|
||||
status?: Status
|
||||
title?: string
|
||||
}
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
const DropdownMenu = (props: IDropdownMenu) => {
|
||||
const {
|
||||
children,
|
||||
disabled,
|
||||
items,
|
||||
onClose,
|
||||
onOpen,
|
||||
onShiftClick,
|
||||
placement: initialPlacement = 'top',
|
||||
src = require('@tabler/icons/dots.svg'),
|
||||
title = 'Menu',
|
||||
...filteredProps
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const arrowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isOnMobile = isUserTouching();
|
||||
|
||||
const { x, y, strategy, refs, middlewareData, placement } = useFloating<HTMLButtonElement>({
|
||||
placement: initialPlacement,
|
||||
middleware: [
|
||||
offset(12),
|
||||
flip(),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const handleClick: React.EventHandler<
|
||||
React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
|
||||
> = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (onShiftClick && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
onShiftClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
} else {
|
||||
handleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* On mobile screens, let's replace the Popper dropdown with a Modal.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
if (isOnMobile) {
|
||||
dispatch(
|
||||
openModal('ACTIONS', {
|
||||
status: filteredProps.status,
|
||||
actions: items,
|
||||
onClick: handleItemClick,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(openDropdownMenu());
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
(refs.reference.current as HTMLButtonElement)?.focus();
|
||||
|
||||
if (isOnMobile) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
} else {
|
||||
closeDropdownMenu();
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdownMenu = () => {
|
||||
dispatch((dispatch, getState) => {
|
||||
const isOpenRedux = getState().dropdown_menu.isOpen;
|
||||
|
||||
if (isOpenRedux) {
|
||||
dispatch(closeDropdownMenuRedux());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (event) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleClick(event);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const i = Number(event.currentTarget.getAttribute('data-index'));
|
||||
const item = items[i];
|
||||
if (!item) return;
|
||||
|
||||
const { action, to } = item;
|
||||
|
||||
handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
action(event);
|
||||
} else if (to) {
|
||||
history.push(to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!refs.floating.current) return;
|
||||
|
||||
const items = Array.from(refs.floating.current.getElementsByTagName('a'));
|
||||
const index = items.indexOf(document.activeElement as any);
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
element = items[index + 1] || items[0];
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = items[index - 1] || items[items.length - 1];
|
||||
} else {
|
||||
element = items[index + 1] || items[0];
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = items[0];
|
||||
break;
|
||||
case 'End':
|
||||
element = items[items.length - 1];
|
||||
break;
|
||||
case 'Escape':
|
||||
handleClose();
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const arrowProps: React.CSSProperties = useMemo(() => {
|
||||
if (middlewareData.arrow) {
|
||||
const { x, y } = middlewareData.arrow;
|
||||
|
||||
const staticPlacement = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
}[placement.split('-')[0]];
|
||||
|
||||
return {
|
||||
left: x !== null ? `${x}px` : '',
|
||||
top: y !== null ? `${y}px` : '',
|
||||
// Ensure the static side gets unset when
|
||||
// flipping to other placements' axes.
|
||||
right: '',
|
||||
bottom: '',
|
||||
[staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`,
|
||||
transform: 'rotate(45deg)',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}, [middlewareData.arrow, placement]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeDropdownMenu();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (refs.floating.current) {
|
||||
(refs.floating.current?.querySelector('li a[role=\'button\']') as HTMLAnchorElement)?.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, false);
|
||||
document.addEventListener('keydown', handleKeyDown, false);
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('touchend', handleDocumentClick);
|
||||
};
|
||||
}
|
||||
}, [isOpen, refs.floating.current]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
React.cloneElement(children, {
|
||||
disabled,
|
||||
onClick: handleClick,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: refs.setReference,
|
||||
})
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
'text-gray-700 dark:text-gray-500': isOpen,
|
||||
})}
|
||||
title={title}
|
||||
src={src}
|
||||
onClick={handleClick}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={refs.setReference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOpen ? (
|
||||
<Portal>
|
||||
<div
|
||||
data-testid='dropdown-menu'
|
||||
ref={refs.setFloating}
|
||||
className={
|
||||
clsx('z-[1001] w-56 rounded-md bg-white py-1 shadow-lg transition-opacity duration-100 focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', {
|
||||
'opacity-0 pointer-events-none': !isOpen,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{items.map((item, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={idx}
|
||||
item={item}
|
||||
index={idx}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Arrow */}
|
||||
<div
|
||||
ref={arrowRef}
|
||||
style={arrowProps}
|
||||
className='pointer-events-none absolute z-[-1] h-3 w-3 bg-white dark:bg-gray-900'
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenu;
|
|
@ -1,3 +0,0 @@
|
|||
export { default } from './dropdown-menu';
|
||||
export type { Menu } from './dropdown-menu';
|
||||
export type { MenuItem } from './dropdown-menu-item';
|
|
@ -1,56 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import GroupHeaderImage from 'soapbox/features/group/components/group-header-image';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||
import GroupRelationship from 'soapbox/features/group/components/group-relationship';
|
||||
|
||||
import GroupAvatar from './groups/group-avatar';
|
||||
import { HStack, Stack, Text } from './ui';
|
||||
|
||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupCard {
|
||||
group: GroupEntity
|
||||
}
|
||||
|
||||
const GroupCard: React.FC<IGroupCard> = ({ group }) => {
|
||||
return (
|
||||
<Stack
|
||||
className='relative h-[240px] rounded-lg border border-solid border-gray-300 bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||
data-testid='group-card'
|
||||
>
|
||||
{/* Group Cover Image */}
|
||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||
<GroupHeaderImage
|
||||
group={group}
|
||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<GroupAvatar group={group} size={64} withRing />
|
||||
</div>
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||
<HStack alignItems='center' space={1.5}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
|
||||
{group.relationship?.pending_requests && (
|
||||
<div className='h-2 w-2 rounded-full bg-secondary-500' />
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||
<GroupRelationship group={group} />
|
||||
<GroupPrivacy group={group} />
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupCard;
|
|
@ -1,37 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
|
||||
import { Avatar } from '../ui';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
interface IGroupAvatar {
|
||||
group: Group
|
||||
size: number
|
||||
withRing?: boolean
|
||||
}
|
||||
|
||||
const GroupAvatar = (props: IGroupAvatar) => {
|
||||
const { group, size, withRing = false } = props;
|
||||
|
||||
const isOwner = group.relationship?.role === GroupRoles.OWNER;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={
|
||||
clsx('relative rounded-full', {
|
||||
'shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.white)]': isOwner && withRing,
|
||||
'dark:shadow-[0_0_0_2px_theme(colors.primary.600),0_0_0_4px_theme(colors.gray.800)]': isOwner && withRing,
|
||||
'shadow-[0_0_0_2px_theme(colors.primary.600)]': isOwner && !withRing,
|
||||
'shadow-[0_0_0_2px_theme(colors.white)] dark:shadow-[0_0_0_2px_theme(colors.gray.800)]': !isOwner && withRing,
|
||||
})
|
||||
}
|
||||
src={group.avatar}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupAvatar;
|
|
@ -1,108 +0,0 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Link, matchPath, useHistory } from 'react-router-dom';
|
||||
|
||||
import { Button, Divider, HStack, Popover, Stack, Text } from 'soapbox/components/ui';
|
||||
import GroupMemberCount from 'soapbox/features/group/components/group-member-count';
|
||||
import GroupPrivacy from 'soapbox/features/group/components/group-privacy';
|
||||
|
||||
import GroupAvatar from '../group-avatar';
|
||||
|
||||
import type { Group } from 'soapbox/schemas';
|
||||
|
||||
interface IGroupPopoverContainer {
|
||||
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
isEnabled: boolean
|
||||
group: Group
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'group.popover.title', defaultMessage: 'Membership required' },
|
||||
summary: { id: 'group.popover.summary', defaultMessage: 'You must be a member of the group in order to reply to this status.' },
|
||||
action: { id: 'group.popover.action', defaultMessage: 'View Group' },
|
||||
});
|
||||
|
||||
const GroupPopover = (props: IGroupPopoverContainer) => {
|
||||
const { children, group, isEnabled } = props;
|
||||
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const path = history.location.pathname;
|
||||
const shouldHideAction = matchPath(path, {
|
||||
path: ['/group/:groupSlug'],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if (!isEnabled) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
interaction='click'
|
||||
referenceElementClassName='cursor-pointer'
|
||||
content={
|
||||
<Stack space={4} className='w-80 pb-4'>
|
||||
<Stack
|
||||
className='relative h-60 rounded-lg bg-white dark:border-primary-800 dark:bg-primary-900'
|
||||
data-testid='group-card'
|
||||
>
|
||||
{/* Group Cover Image */}
|
||||
<Stack grow className='relative basis-1/2 rounded-t-lg bg-primary-100 dark:bg-gray-800'>
|
||||
{group.header && (
|
||||
<img
|
||||
className='absolute inset-0 h-full w-full rounded-t-lg object-cover'
|
||||
src={group.header}
|
||||
alt=''
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Group Avatar */}
|
||||
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||
<GroupAvatar group={group} size={64} withRing />
|
||||
</div>
|
||||
|
||||
{/* Group Info */}
|
||||
<Stack alignItems='center' justifyContent='end' grow className='basis-1/2 py-4' space={0.5}>
|
||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={{ __html: group.display_name_html }} />
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
|
||||
<GroupPrivacy group={group} />
|
||||
<GroupMemberCount group={group} />
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack space={0.5} className='px-4'>
|
||||
<Text weight='semibold'>
|
||||
{intl.formatMessage(messages.title)}
|
||||
</Text>
|
||||
<Text theme='muted'>
|
||||
{intl.formatMessage(messages.summary)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{!shouldHideAction && (
|
||||
<div className='px-4'>
|
||||
<Link to={`/group/${group.slug}`}>
|
||||
<Button type='button' theme='secondary' block>
|
||||
{intl.formatMessage(messages.action)}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
isFlush
|
||||
children={
|
||||
<div className='inline-block'>{children}</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPopover;
|
|
@ -1,62 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useGroupLookup } from 'soapbox/api/hooks';
|
||||
import ColumnLoading from 'soapbox/features/ui/components/column-loading';
|
||||
|
||||
import { Layout } from '../ui';
|
||||
|
||||
interface IGroupLookup {
|
||||
params: {
|
||||
groupSlug: string
|
||||
}
|
||||
}
|
||||
|
||||
interface IMaybeGroupLookup {
|
||||
params?: {
|
||||
groupSlug?: string
|
||||
groupId?: string
|
||||
}
|
||||
}
|
||||
|
||||
function GroupLookupHoc(Component: React.ComponentType<{ params: { groupId: string } }>) {
|
||||
const GroupLookup: React.FC<IGroupLookup> = (props) => {
|
||||
const { entity: group } = useGroupLookup(props.params.groupSlug);
|
||||
|
||||
if (!group) return (
|
||||
<>
|
||||
<Layout.Main>
|
||||
<ColumnLoading />
|
||||
</Layout.Main>
|
||||
|
||||
<Layout.Aside />
|
||||
</>
|
||||
);
|
||||
|
||||
const newProps = {
|
||||
...props,
|
||||
params: {
|
||||
...props.params,
|
||||
id: group.id,
|
||||
groupId: group.id,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Component {...newProps} />
|
||||
);
|
||||
};
|
||||
|
||||
const MaybeGroupLookup: React.FC<IMaybeGroupLookup> = (props) => {
|
||||
const { params } = props;
|
||||
|
||||
if (params?.groupId) {
|
||||
return <Component {...props} params={{ ...params, groupId: params.groupId }} />;
|
||||
} else {
|
||||
return <GroupLookup {...props} params={{ ...params, groupSlug: params?.groupSlug || '' }} />;
|
||||
}
|
||||
};
|
||||
|
||||
return MaybeGroupLookup;
|
||||
}
|
||||
|
||||
export default GroupLookupHoc;
|
|
@ -1,11 +0,0 @@
|
|||
type HOC<P, R> = (Component: React.ComponentType<P>) => React.ComponentType<R>
|
||||
type AsyncComponent<P> = () => Promise<{ default: React.ComponentType<P> }>
|
||||
|
||||
const withHoc = <P, R>(asyncComponent: AsyncComponent<P>, hoc: HOC<P, R>) => {
|
||||
return async () => {
|
||||
const { default: component } = await asyncComponent();
|
||||
return { default: hoc(component) };
|
||||
};
|
||||
};
|
||||
|
||||
export default withHoc;
|
|
@ -1,100 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
interface IIconButton extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'disabled' | 'onClick' | 'onKeyDown' | 'onKeyPress' | 'onKeyUp' | 'onMouseDown' | 'onMouseEnter' | 'onMouseLeave' | 'tabIndex' | 'title'> {
|
||||
active?: boolean
|
||||
expanded?: boolean
|
||||
iconClassName?: string
|
||||
pressed?: boolean
|
||||
size?: number
|
||||
src: string
|
||||
text?: React.ReactNode
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IIconButton> = ({
|
||||
active,
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
iconClassName,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
onKeyPress,
|
||||
onMouseDown,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
pressed,
|
||||
size = 18,
|
||||
src,
|
||||
tabIndex = 0,
|
||||
text,
|
||||
title,
|
||||
}) => {
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!disabled && onMouseDown) {
|
||||
onMouseDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!disabled && onKeyDown) {
|
||||
onKeyDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (!disabled && onKeyUp) {
|
||||
onKeyUp(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
|
||||
if (onKeyPress && !disabled) {
|
||||
onKeyPress(e);
|
||||
}
|
||||
};
|
||||
|
||||
const classes = clsx(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyPress={handleKeyPress}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
type='button'
|
||||
>
|
||||
<div>
|
||||
<Icon className={iconClassName} src={src} aria-hidden='true' />
|
||||
</div>
|
||||
{text && <span className='icon-button__text'>{text}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
|
@ -1,54 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
|
||||
interface IPendingItemsRow {
|
||||
/** Path to navigate the user when clicked. */
|
||||
to: string
|
||||
/** Number of pending items. */
|
||||
count: number
|
||||
/** Size of the icon. */
|
||||
size?: 'md' | 'lg'
|
||||
}
|
||||
|
||||
const PendingItemsRow: React.FC<IPendingItemsRow> = ({ to, count, size = 'md' }) => {
|
||||
return (
|
||||
<Link to={to} className='group' data-testid='pending-items-row'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<div className={clsx('rounded-full bg-primary-200 text-primary-500 dark:bg-primary-800 dark:text-primary-200', {
|
||||
'p-3': size === 'lg',
|
||||
'p-2.5': size === 'md',
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/exclamation-circle.svg')}
|
||||
className={clsx({
|
||||
'h-5 w-5': size === 'md',
|
||||
'h-7 w-7': size === 'lg',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text weight='bold' size='md'>
|
||||
<FormattedMessage
|
||||
id='groups.pending.count'
|
||||
defaultMessage='{number, plural, one {# pending request} other {# pending requests}}'
|
||||
values={{ number: count }}
|
||||
/>
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-right.svg')}
|
||||
className='h-5 w-5 text-gray-600 transition-colors group-hover:text-gray-700 dark:text-gray-600 dark:group-hover:text-gray-500'
|
||||
/>
|
||||
</HStack>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export { PendingItemsRow };
|
|
@ -1,123 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { EmojiSelector, Portal } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
interface IStatusReactionWrapper {
|
||||
statusId: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
/** Provides emoji reaction functionality to the underlying button component */
|
||||
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => {
|
||||
const dispatch = useAppDispatch();
|
||||
const ownAccount = useOwnAccount();
|
||||
const status = useAppSelector(state => state.statuses.get(statusId));
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
if (!isUserTouching()) {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
// Unless the user is touching, delay closing the emoji selector briefly
|
||||
// so the user can move the mouse diagonally to make a selection.
|
||||
if (isUserTouching()) {
|
||||
setVisible(false);
|
||||
} else {
|
||||
timeout.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReact = (emoji: string, custom?: string): void => {
|
||||
if (ownAccount) {
|
||||
dispatch(simpleEmojiReact(status, emoji, custom));
|
||||
} else {
|
||||
handleUnauthorized();
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji)?.get('name') || '👍';
|
||||
|
||||
if (isUserTouching()) {
|
||||
if (ownAccount) {
|
||||
if (visible) {
|
||||
handleReact(meEmojiReact);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
} else {
|
||||
handleUnauthorized();
|
||||
}
|
||||
} else {
|
||||
handleReact(meEmojiReact);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleUnauthorized = () => {
|
||||
dispatch(openModal('UNAUTHORIZED', {
|
||||
action: 'FAVOURITE',
|
||||
ap_id: status.url,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{React.cloneElement(children, {
|
||||
onClick: handleClick,
|
||||
ref: setReferenceElement,
|
||||
})}
|
||||
|
||||
{visible && (
|
||||
<Portal>
|
||||
<EmojiSelector
|
||||
placement='top-start'
|
||||
referenceElement={referenceElement}
|
||||
onReact={handleReact}
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusReactionWrapper;
|
|
@ -1,45 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Text } from '../ui';
|
||||
|
||||
interface IStatusInfo {
|
||||
avatarSize: number
|
||||
icon: React.ReactNode
|
||||
text: React.ReactNode
|
||||
}
|
||||
|
||||
const StatusInfo = (props: IStatusInfo) => {
|
||||
const { avatarSize, icon, text } = props;
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/aria-role
|
||||
role='status-info'
|
||||
onClick={onClick}
|
||||
>
|
||||
<HStack
|
||||
space={3}
|
||||
alignItems='center'
|
||||
className='cursor-default text-xs font-medium text-gray-700 rtl:space-x-reverse dark:text-gray-600'
|
||||
>
|
||||
<div
|
||||
className='flex justify-end'
|
||||
style={{ width: avatarSize }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<Text size='xs' theme='muted' weight='medium'>
|
||||
{text}
|
||||
</Text>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusInfo;
|
|
@ -1,112 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useDimensions } from 'soapbox/hooks';
|
||||
|
||||
import HStack from '../hstack/hstack';
|
||||
import Icon from '../icon/icon';
|
||||
|
||||
interface ICarousel {
|
||||
children: any
|
||||
/** Optional height to force on controls */
|
||||
controlsHeight?: number
|
||||
/** How many items in the carousel */
|
||||
itemCount: number
|
||||
/** The minimum width per item */
|
||||
itemWidth: number
|
||||
/** Should the controls be disabled? */
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Carousel
|
||||
*/
|
||||
const Carousel: React.FC<ICarousel> = (props): JSX.Element => {
|
||||
const { children, controlsHeight, isDisabled, itemCount, itemWidth } = props;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [ref, setContainerRef, { width: finalContainerWidth }] = useDimensions();
|
||||
const containerWidth = finalContainerWidth || ref?.clientWidth;
|
||||
|
||||
const [pageSize, setPageSize] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
const numberOfPages = Math.ceil(itemCount / pageSize);
|
||||
const width = containerWidth / (Math.floor(containerWidth / itemWidth));
|
||||
|
||||
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
|
||||
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
|
||||
|
||||
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
|
||||
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
|
||||
|
||||
const renderChildren = () => {
|
||||
if (typeof children === 'function') {
|
||||
return children({ width: width || 'auto' });
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (containerWidth) {
|
||||
setPageSize(Math.round(containerWidth / width));
|
||||
}
|
||||
}, [containerWidth, width]);
|
||||
|
||||
return (
|
||||
<HStack alignItems='stretch'>
|
||||
<div
|
||||
className='z-10 flex w-5 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
height: controlsHeight || 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid='prev-page'
|
||||
onClick={handlePrevPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasPrevPage || isDisabled}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-left.svg')}
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
style={{
|
||||
transform: `translateX(-${(currentPage - 1) * 100}%)`,
|
||||
}}
|
||||
className='transition-all duration-500 ease-out'
|
||||
ref={setContainerRef}
|
||||
>
|
||||
{renderChildren()}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='z-10 flex w-5 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
height: controlsHeight || 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-testid='next-page'
|
||||
onClick={handleNextPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasNextPage || isDisabled}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/chevron-right.svg')}
|
||||
className='h-5 w-5 text-black dark:text-white'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
|
@ -1,120 +0,0 @@
|
|||
import {
|
||||
arrow,
|
||||
autoPlacement,
|
||||
FloatingArrow,
|
||||
offset,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useHover,
|
||||
useInteractions,
|
||||
useTransitionStyles,
|
||||
} from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import Portal from '../portal/portal';
|
||||
|
||||
interface IPopover {
|
||||
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>
|
||||
/** The content of the popover */
|
||||
content: React.ReactNode
|
||||
/** Should we remove padding on the Popover */
|
||||
isFlush?: boolean
|
||||
/** Should the popover trigger via click or hover */
|
||||
interaction?: 'click' | 'hover'
|
||||
/** Add a class to the reference (trigger) element */
|
||||
referenceElementClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover
|
||||
*
|
||||
* Similar to tooltip, but requires a click and is used for larger blocks
|
||||
* of information.
|
||||
*/
|
||||
const Popover: React.FC<IPopover> = (props) => {
|
||||
const { children, content, referenceElementClassName, interaction = 'hover', isFlush = false } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const arrowRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
const { x, y, strategy, refs, context } = useFloating({
|
||||
open: isOpen,
|
||||
onOpenChange: setIsOpen,
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
autoPlacement({
|
||||
allowedPlacements: ['top', 'bottom'],
|
||||
}),
|
||||
offset(10),
|
||||
arrow({
|
||||
element: arrowRef,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { isMounted, styles } = useTransitionStyles(context, {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
duration: {
|
||||
open: 200,
|
||||
close: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const click = useClick(context, { enabled: interaction === 'click' });
|
||||
const hover = useHover(context, { enabled: interaction === 'hover' });
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
hover,
|
||||
dismiss,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
ref: refs.setReference,
|
||||
...getReferenceProps(),
|
||||
className: clsx(children.props.className, referenceElementClassName),
|
||||
})}
|
||||
|
||||
{(isMounted) && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
...styles,
|
||||
}}
|
||||
className={
|
||||
clsx({
|
||||
'z-40 rounded-lg bg-white shadow-2xl dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
|
||||
'p-6': !isFlush,
|
||||
})
|
||||
}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{content}
|
||||
|
||||
<FloatingArrow
|
||||
ref={arrowRef}
|
||||
context={context}
|
||||
className='-ml-2 fill-white dark:hidden' /** -ml-2 to fix offcenter arrow */
|
||||
tipRadius={3}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popover;
|
|
@ -1,31 +0,0 @@
|
|||
import React, { useLayoutEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface IPortal {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal
|
||||
*/
|
||||
const Portal: React.FC<IPortal> = ({ children }) => {
|
||||
const [isRendered, setIsRendered] = useState<boolean>(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsRendered(true);
|
||||
}, []);
|
||||
|
||||
|
||||
if (!isRendered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
ReactDOM.createPortal(
|
||||
children,
|
||||
document.getElementById('soapbox') as HTMLDivElement,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Portal;
|
|
@ -1,21 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import Account, { IAccount } from '../components/account';
|
||||
import { makeGetAccount } from '../selectors';
|
||||
|
||||
interface IAccountContainer extends Omit<IAccount, 'account'> {
|
||||
id: string
|
||||
}
|
||||
|
||||
const AccountContainer: React.FC<IAccountContainer> = ({ id, ...props }) => {
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
const account = useAppSelector(state => getAccount(state, id));
|
||||
|
||||
return (
|
||||
<Account account={account!} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountContainer;
|
|
@ -1,209 +0,0 @@
|
|||
import {
|
||||
deleteEntities,
|
||||
dismissEntities,
|
||||
entitiesFetchFail,
|
||||
entitiesFetchRequest,
|
||||
entitiesFetchSuccess,
|
||||
importEntities,
|
||||
incrementEntities,
|
||||
} from '../actions';
|
||||
import reducer, { State } from '../reducer';
|
||||
import { createListState } from '../utils';
|
||||
|
||||
import type { EntityCache } from '../types';
|
||||
|
||||
interface TestEntity {
|
||||
id: string
|
||||
msg: string
|
||||
}
|
||||
|
||||
test('import entities', () => {
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '1', msg: 'yolo' },
|
||||
{ id: '2', msg: 'benis' },
|
||||
{ id: '3', msg: 'boop' },
|
||||
];
|
||||
|
||||
const action = importEntities(entities, 'TestEntity');
|
||||
const result = reducer(undefined, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache.store['1']!.msg).toBe('yolo');
|
||||
expect(Object.values(cache.lists).length).toBe(0);
|
||||
});
|
||||
|
||||
test('import entities into a list', () => {
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '1', msg: 'yolo' },
|
||||
{ id: '2', msg: 'benis' },
|
||||
{ id: '3', msg: 'boop' },
|
||||
];
|
||||
|
||||
const action = importEntities(entities, 'TestEntity', 'thingies');
|
||||
const result = reducer(undefined, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache.store['2']!.msg).toBe('benis');
|
||||
expect(cache.lists.thingies!.ids.size).toBe(3);
|
||||
expect(cache.lists.thingies!.state.totalCount).toBe(3);
|
||||
|
||||
// Now try adding an additional item.
|
||||
const entities2: TestEntity[] = [
|
||||
{ id: '4', msg: 'hehe' },
|
||||
];
|
||||
|
||||
const action2 = importEntities(entities2, 'TestEntity', 'thingies');
|
||||
const result2 = reducer(result, action2);
|
||||
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache2.store['4']!.msg).toBe('hehe');
|
||||
expect(cache2.lists.thingies!.ids.size).toBe(4);
|
||||
expect(cache2.lists.thingies!.state.totalCount).toBe(4);
|
||||
|
||||
// Finally, update an item.
|
||||
const entities3: TestEntity[] = [
|
||||
{ id: '2', msg: 'yolofam' },
|
||||
];
|
||||
|
||||
const action3 = importEntities(entities3, 'TestEntity', 'thingies');
|
||||
const result3 = reducer(result2, action3);
|
||||
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache3.store['2']!.msg).toBe('yolofam');
|
||||
expect(cache3.lists.thingies!.ids.size).toBe(4); // unchanged
|
||||
expect(cache3.lists.thingies!.state.totalCount).toBe(4);
|
||||
});
|
||||
|
||||
test('fetching updates the list state', () => {
|
||||
const action = entitiesFetchRequest('TestEntity', 'thingies');
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.fetching).toBe(true);
|
||||
});
|
||||
|
||||
test('failure adds the error to the state', () => {
|
||||
const error = new Error('whoopsie');
|
||||
|
||||
const action = entitiesFetchFail('TestEntity', 'thingies', error);
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.error).toBe(error);
|
||||
});
|
||||
|
||||
test('import entities with override', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
thingies: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '4', msg: 'yolo' },
|
||||
{ id: '5', msg: 'benis' },
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', {
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: 2,
|
||||
error: null,
|
||||
fetched: true,
|
||||
fetching: false,
|
||||
lastFetchedAt: now,
|
||||
invalid: false,
|
||||
}, true);
|
||||
|
||||
const result = reducer(state, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']);
|
||||
expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked
|
||||
});
|
||||
|
||||
test('deleting items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
'': {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = deleteEntities(['3', '1'], 'TestEntity');
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } });
|
||||
expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']);
|
||||
expect(result.TestEntity!.lists['']!.state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test('dismiss items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
yolo: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = dismissEntities(['3', '1'], 'TestEntity', 'yolo');
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store);
|
||||
expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']);
|
||||
expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test('increment items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
thingies: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = incrementEntities('TestEntity', 'thingies', 1);
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(4);
|
||||
});
|
||||
|
||||
test('decrement items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
thingies: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = incrementEntities('TestEntity', 'thingies', -1);
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(2);
|
||||
});
|
|
@ -1,129 +0,0 @@
|
|||
import type { Entity, EntityListState, ImportPosition } from './types';
|
||||
|
||||
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
|
||||
const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const;
|
||||
const ENTITIES_INCREMENT = 'ENTITIES_INCREMENT' as const;
|
||||
const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const;
|
||||
const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const;
|
||||
const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const;
|
||||
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
|
||||
|
||||
/** Action to import entities into the cache. */
|
||||
function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) {
|
||||
return {
|
||||
type: ENTITIES_IMPORT,
|
||||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteEntitiesOpts {
|
||||
preserveLists?: boolean
|
||||
}
|
||||
|
||||
function deleteEntities(ids: Iterable<string>, entityType: string, opts: DeleteEntitiesOpts = {}) {
|
||||
return {
|
||||
type: ENTITIES_DELETE,
|
||||
ids,
|
||||
entityType,
|
||||
opts,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissEntities(ids: Iterable<string>, entityType: string, listKey: string) {
|
||||
return {
|
||||
type: ENTITIES_DISMISS,
|
||||
ids,
|
||||
entityType,
|
||||
listKey,
|
||||
};
|
||||
}
|
||||
|
||||
function incrementEntities(entityType: string, listKey: string, diff: number) {
|
||||
return {
|
||||
type: ENTITIES_INCREMENT,
|
||||
entityType,
|
||||
listKey,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchRequest(entityType: string, listKey?: string) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_REQUEST,
|
||||
entityType,
|
||||
listKey,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchSuccess(
|
||||
entities: Entity[],
|
||||
entityType: string,
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_SUCCESS,
|
||||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
newState,
|
||||
overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_FAIL,
|
||||
entityType,
|
||||
listKey,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateEntityList(entityType: string, listKey: string) {
|
||||
return {
|
||||
type: ENTITIES_INVALIDATE_LIST,
|
||||
entityType,
|
||||
listKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Any action pertaining to entities. */
|
||||
type EntityAction =
|
||||
ReturnType<typeof importEntities>
|
||||
| ReturnType<typeof deleteEntities>
|
||||
| ReturnType<typeof dismissEntities>
|
||||
| ReturnType<typeof incrementEntities>
|
||||
| ReturnType<typeof entitiesFetchRequest>
|
||||
| ReturnType<typeof entitiesFetchSuccess>
|
||||
| ReturnType<typeof entitiesFetchFail>
|
||||
| ReturnType<typeof invalidateEntityList>;
|
||||
|
||||
export {
|
||||
ENTITIES_IMPORT,
|
||||
ENTITIES_DELETE,
|
||||
ENTITIES_DISMISS,
|
||||
ENTITIES_INCREMENT,
|
||||
ENTITIES_FETCH_REQUEST,
|
||||
ENTITIES_FETCH_SUCCESS,
|
||||
ENTITIES_FETCH_FAIL,
|
||||
ENTITIES_INVALIDATE_LIST,
|
||||
importEntities,
|
||||
deleteEntities,
|
||||
dismissEntities,
|
||||
incrementEntities,
|
||||
entitiesFetchRequest,
|
||||
entitiesFetchSuccess,
|
||||
entitiesFetchFail,
|
||||
invalidateEntityList,
|
||||
EntityAction,
|
||||
};
|
||||
|
||||
export type { DeleteEntitiesOpts };
|
|
@ -1,9 +0,0 @@
|
|||
export enum Entities {
|
||||
ACCOUNTS = 'Accounts',
|
||||
GROUPS = 'Groups',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_TAGS = 'GroupTags',
|
||||
RELATIONSHIPS = 'Relationships',
|
||||
STATUSES = 'Statuses'
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
export { useEntities } from './useEntities';
|
||||
export { useEntity } from './useEntity';
|
||||
export { useEntityActions } from './useEntityActions';
|
||||
export { useEntityLookup } from './useEntityLookup';
|
||||
export { useCreateEntity } from './useCreateEntity';
|
||||
export { useDeleteEntity } from './useDeleteEntity';
|
||||
export { useDismissEntity } from './useDismissEntity';
|
||||
export { useIncrementEntity } from './useIncrementEntity';
|
|
@ -1,47 +0,0 @@
|
|||
import type { Entity } from '../types';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import type z from 'zod';
|
||||
|
||||
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
|
||||
|
||||
/**
|
||||
* Tells us where to find/store the entity in the cache.
|
||||
* This value is accepted in hooks, but needs to be parsed into an `EntitiesPath`
|
||||
* before being passed to the store.
|
||||
*/
|
||||
type ExpandedEntitiesPath = [
|
||||
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
|
||||
entityType: string,
|
||||
/**
|
||||
* Name of a particular index of this entity type.
|
||||
* Multiple params get combined into one string with a `:` separator.
|
||||
*/
|
||||
...listKeys: string[],
|
||||
]
|
||||
|
||||
/** Used to look up an entity in a list. */
|
||||
type EntitiesPath = [entityType: string, listKey: string]
|
||||
|
||||
/** Used to look up a single entity by its ID. */
|
||||
type EntityPath = [entityType: string, entityId: string]
|
||||
|
||||
/** Callback functions for entity actions. */
|
||||
interface EntityCallbacks<Value, Error = unknown> {
|
||||
onSuccess?(value: Value): void
|
||||
onError?(error: Error): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed into hooks to make requests.
|
||||
* Must return an Axios response.
|
||||
*/
|
||||
type EntityFn<T> = (value: T) => Promise<AxiosResponse>
|
||||
|
||||
export type {
|
||||
EntitySchema,
|
||||
ExpandedEntitiesPath,
|
||||
EntitiesPath,
|
||||
EntityPath,
|
||||
EntityCallbacks,
|
||||
EntityFn,
|
||||
};
|
|
@ -1,56 +0,0 @@
|
|||
import { AxiosError } from 'axios';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
|
||||
interface UseCreateEntityOpts<TEntity extends Entity = Entity> {
|
||||
schema?: EntitySchema<TEntity>
|
||||
}
|
||||
|
||||
function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
entityFn: EntityFn<Data>,
|
||||
opts: UseCreateEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isSubmitting, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity, AxiosError> = {}): Promise<void> {
|
||||
try {
|
||||
const result = await setPromise(entityFn(data));
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entity = schema.parse(result.data);
|
||||
|
||||
// TODO: optimistic updating
|
||||
dispatch(importEntities([entity], entityType, listKey, 'start'));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCreateEntity };
|
|
@ -1,54 +0,0 @@
|
|||
import { useAppDispatch, useGetState, useLoading } from 'soapbox/hooks';
|
||||
|
||||
import { deleteEntities, importEntities } from '../actions';
|
||||
|
||||
import type { EntityCallbacks, EntityFn } from './types';
|
||||
|
||||
/**
|
||||
* Optimistically deletes an entity from the store.
|
||||
* This hook should be used to globally delete an entity from all lists.
|
||||
* To remove an entity from a single list, see `useDismissEntity`.
|
||||
*/
|
||||
function useDeleteEntity(
|
||||
entityType: string,
|
||||
entityFn: EntityFn<string>,
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
const [isSubmitting, setPromise] = useLoading();
|
||||
|
||||
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
|
||||
// Get the entity before deleting, so we can reverse the action if the API request fails.
|
||||
const entity = getState().entities[entityType]?.store[entityId];
|
||||
|
||||
// Optimistically delete the entity from the _store_ but keep the lists in tact.
|
||||
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
|
||||
|
||||
try {
|
||||
await setPromise(entityFn(entityId));
|
||||
|
||||
// Success - finish deleting entity from the state.
|
||||
dispatch(deleteEntities([entityId], entityType));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entityId);
|
||||
}
|
||||
} catch (e) {
|
||||
if (entity) {
|
||||
// If the API failed, reimport the entity.
|
||||
dispatch(importEntities([entity], entityType));
|
||||
}
|
||||
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deleteEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDeleteEntity };
|
|
@ -1,32 +0,0 @@
|
|||
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
||||
|
||||
import { dismissEntities } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntityFn, ExpandedEntitiesPath } from './types';
|
||||
|
||||
/**
|
||||
* Removes an entity from a specific list.
|
||||
* To remove an entity globally from all lists, see `useDeleteEntity`.
|
||||
*/
|
||||
function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn<string>) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isLoading, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
// TODO: optimistic dismissing
|
||||
async function dismissEntity(entityId: string) {
|
||||
const result = await setPromise(entityFn(entityId));
|
||||
dispatch(dismissEntities([entityId], entityType, listKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
dismissEntity,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDismissEntity };
|
|
@ -1,178 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { getNextLink, getPrevLink } from 'soapbox/api';
|
||||
import { useApi, useAppDispatch, useAppSelector, useGetState } from 'soapbox/hooks';
|
||||
import { filteredArray } from 'soapbox/schemas/utils';
|
||||
import { realNumberSchema } from 'soapbox/utils/numbers';
|
||||
|
||||
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { Entity, EntityListState } from '../types';
|
||||
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
/** Additional options for the hook. */
|
||||
interface UseEntitiesOpts<TEntity extends Entity> {
|
||||
/** A zod schema to parse the API entities. */
|
||||
schema?: EntitySchema<TEntity>
|
||||
/**
|
||||
* Time (milliseconds) until this query becomes stale and should be refetched.
|
||||
* It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching.
|
||||
*/
|
||||
staleTime?: number
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** A hook for fetching and displaying API entities. */
|
||||
function useEntities<TEntity extends Entity>(
|
||||
/** Tells us where to find/store the entity in the cache. */
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
|
||||
entityFn: EntityFn<void>,
|
||||
/** Additional options for the hook. */
|
||||
opts: UseEntitiesOpts<TEntity> = {},
|
||||
) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
|
||||
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
|
||||
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isFetching = useListState(path, 'fetching');
|
||||
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
||||
const isFetched = useListState(path, 'fetched');
|
||||
const isError = !!useListState(path, 'error');
|
||||
const totalCount = useListState(path, 'totalCount');
|
||||
const isInvalid = useListState(path, 'invalid');
|
||||
|
||||
const next = useListState(path, 'next');
|
||||
const prev = useListState(path, 'prev');
|
||||
|
||||
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
|
||||
// Get `isFetching` state from the store again to prevent race conditions.
|
||||
const isFetching = selectListState(getState(), path, 'fetching');
|
||||
if (isFetching) return;
|
||||
|
||||
dispatch(entitiesFetchRequest(entityType, listKey));
|
||||
try {
|
||||
const response = await req();
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entities = filteredArray(schema).parse(response.data);
|
||||
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
|
||||
const totalCount = parsedCount.success ? parsedCount.data : undefined;
|
||||
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, {
|
||||
next: getNextLink(response),
|
||||
prev: getPrevLink(response),
|
||||
totalCount: Number(totalCount) >= entities.length ? totalCount : undefined,
|
||||
fetching: false,
|
||||
fetched: true,
|
||||
error: null,
|
||||
lastFetchedAt: new Date(),
|
||||
invalid: false,
|
||||
}, overwrite));
|
||||
} catch (error) {
|
||||
dispatch(entitiesFetchFail(entityType, listKey, error));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEntities = async(): Promise<void> => {
|
||||
await fetchPage(entityFn, 'end', true);
|
||||
};
|
||||
|
||||
const fetchNextPage = async(): Promise<void> => {
|
||||
if (next) {
|
||||
await fetchPage(() => api.get(next), 'end');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreviousPage = async(): Promise<void> => {
|
||||
if (prev) {
|
||||
await fetchPage(() => api.get(prev), 'start');
|
||||
}
|
||||
};
|
||||
|
||||
const invalidate = () => {
|
||||
dispatch(invalidateEntityList(entityType, listKey));
|
||||
};
|
||||
|
||||
const staleTime = opts.staleTime ?? 60000;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (isFetching) return;
|
||||
const isUnset = !lastFetchedAt;
|
||||
const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false;
|
||||
|
||||
if (isInvalid || isUnset || isStale) {
|
||||
fetchEntities();
|
||||
}
|
||||
}, [isEnabled, ...path]);
|
||||
|
||||
return {
|
||||
entities,
|
||||
fetchEntities,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasNextPage: !!next,
|
||||
hasPreviousPage: !!prev,
|
||||
totalCount,
|
||||
isError,
|
||||
isFetched,
|
||||
isFetching,
|
||||
isLoading: isFetching && entities.length === 0,
|
||||
invalidate,
|
||||
/** The `X-Total-Count` from the API if available, or the length of items in the store. */
|
||||
count: typeof totalCount === 'number' ? totalCount : entities.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get cache at path from Redux. */
|
||||
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
|
||||
|
||||
/** Get list at path from Redux. */
|
||||
const selectList = (state: RootState, path: EntitiesPath) => {
|
||||
const [, ...listKeys] = path;
|
||||
const listKey = listKeys.join(':');
|
||||
|
||||
return selectCache(state, path)?.lists[listKey];
|
||||
};
|
||||
|
||||
/** Select a particular item from a list state. */
|
||||
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
|
||||
const listState = selectList(state, path)?.state;
|
||||
return listState ? listState[key] : undefined;
|
||||
}
|
||||
|
||||
/** Hook to get a particular item from a list state. */
|
||||
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
|
||||
return useAppSelector(state => selectListState(state, path, key));
|
||||
}
|
||||
|
||||
/** Get list of entities from Redux. */
|
||||
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
|
||||
const cache = selectCache(state, path);
|
||||
const list = selectList(state, path);
|
||||
|
||||
const entityIds = list?.ids;
|
||||
|
||||
return entityIds ? (
|
||||
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
|
||||
const entity = cache?.store[id];
|
||||
if (entity) {
|
||||
result.push(entity as TEntity);
|
||||
}
|
||||
return result;
|
||||
}, [])
|
||||
) : [];
|
||||
}
|
||||
|
||||
export {
|
||||
useEntities,
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { EntitySchema, EntityPath, EntityFn } from './types';
|
||||
|
||||
/** Additional options for the hook. */
|
||||
interface UseEntityOpts<TEntity extends Entity> {
|
||||
/** A zod schema to parse the API entity. */
|
||||
schema?: EntitySchema<TEntity>
|
||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||
refetch?: boolean
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function useEntity<TEntity extends Entity>(
|
||||
path: EntityPath,
|
||||
entityFn: EntityFn<void>,
|
||||
opts: UseEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const [isFetching, setPromise] = useLoading(true);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [entityType, entityId] = path;
|
||||
|
||||
const defaultSchema = z.custom<TEntity>();
|
||||
const schema = opts.schema || defaultSchema;
|
||||
|
||||
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
try {
|
||||
const response = await setPromise(entityFn());
|
||||
const entity = schema.parse(response.data);
|
||||
dispatch(importEntities([entity], entityType));
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
fetchEntity,
|
||||
isFetching,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
useEntity,
|
||||
type UseEntityOpts,
|
||||
};
|
|
@ -1,45 +0,0 @@
|
|||
import { useApi } from 'soapbox/hooks';
|
||||
|
||||
import { useCreateEntity } from './useCreateEntity';
|
||||
import { useDeleteEntity } from './useDeleteEntity';
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { Entity } from '../types';
|
||||
import type { EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
|
||||
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
||||
schema?: EntitySchema<TEntity>
|
||||
}
|
||||
|
||||
interface EntityActionEndpoints {
|
||||
delete?: string
|
||||
patch?: string
|
||||
post?: string
|
||||
}
|
||||
|
||||
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
endpoints: EntityActionEndpoints,
|
||||
opts: UseEntityActionsOpts<TEntity> = {},
|
||||
) {
|
||||
const api = useApi();
|
||||
const { entityType, path } = parseEntitiesPath(expandedPath);
|
||||
|
||||
const { deleteEntity, isSubmitting: deleteSubmitting } =
|
||||
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
|
||||
|
||||
const { createEntity, isSubmitting: createSubmitting } =
|
||||
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
|
||||
|
||||
const { createEntity: updateEntity, isSubmitting: updateSubmitting } =
|
||||
useCreateEntity<TEntity, Data>(path, (data) => api.patch(endpoints.patch!, data), opts);
|
||||
|
||||
return {
|
||||
createEntity,
|
||||
deleteEntity,
|
||||
updateEntity,
|
||||
isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useEntityActions };
|
|
@ -1,69 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
|
||||
import { type RootState } from 'soapbox/store';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
import { Entity } from '../types';
|
||||
|
||||
import { EntityFn } from './types';
|
||||
import { type UseEntityOpts } from './useEntity';
|
||||
|
||||
/** Entities will be filtered through this function until it returns true. */
|
||||
type LookupFn<TEntity extends Entity> = (entity: TEntity) => boolean
|
||||
|
||||
function useEntityLookup<TEntity extends Entity>(
|
||||
entityType: string,
|
||||
lookupFn: LookupFn<TEntity>,
|
||||
entityFn: EntityFn<void>,
|
||||
opts: UseEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const { schema = z.custom<TEntity>() } = opts;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [isFetching, setPromise] = useLoading(true);
|
||||
|
||||
const entity = useAppSelector(state => findEntity(state, entityType, lookupFn));
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
try {
|
||||
const response = await setPromise(entityFn());
|
||||
const entity = schema.parse(response.data);
|
||||
dispatch(importEntities([entity], entityType));
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
fetchEntity,
|
||||
isFetching,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
function findEntity<TEntity extends Entity>(
|
||||
state: RootState,
|
||||
entityType: string,
|
||||
lookupFn: LookupFn<TEntity>,
|
||||
) {
|
||||
const cache = state.entities[entityType];
|
||||
|
||||
if (cache) {
|
||||
return (Object.values(cache.store) as TEntity[]).find(lookupFn);
|
||||
}
|
||||
}
|
||||
|
||||
export { useEntityLookup };
|
|
@ -1,37 +0,0 @@
|
|||
import { useAppDispatch, useLoading } from 'soapbox/hooks';
|
||||
|
||||
import { incrementEntities } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntityFn, ExpandedEntitiesPath } from './types';
|
||||
|
||||
/**
|
||||
* Increases (or decreases) the `totalCount` in the entity list by the specified amount.
|
||||
* This only works if the API returns an `X-Total-Count` header and your components read it.
|
||||
*/
|
||||
function useIncrementEntity(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
diff: number,
|
||||
entityFn: EntityFn<string>,
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isLoading, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
async function incrementEntity(entityId: string): Promise<void> {
|
||||
dispatch(incrementEntities(entityType, listKey, diff));
|
||||
try {
|
||||
await setPromise(entityFn(entityId));
|
||||
} catch (e) {
|
||||
dispatch(incrementEntities(entityType, listKey, diff * -1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
incrementEntity,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export { useIncrementEntity };
|
|
@ -1,16 +0,0 @@
|
|||
import type { EntitiesPath, ExpandedEntitiesPath } from './types';
|
||||
|
||||
function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
|
||||
const [entityType, ...listKeys] = expandedPath;
|
||||
const listKey = (listKeys || []).join(':');
|
||||
const path: EntitiesPath = [entityType, listKey];
|
||||
|
||||
return {
|
||||
entityType,
|
||||
listKey,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export { parseEntitiesPath };
|
|
@ -1,184 +0,0 @@
|
|||
import produce, { enableMapSet } from 'immer';
|
||||
|
||||
import {
|
||||
ENTITIES_IMPORT,
|
||||
ENTITIES_DELETE,
|
||||
ENTITIES_DISMISS,
|
||||
ENTITIES_FETCH_REQUEST,
|
||||
ENTITIES_FETCH_SUCCESS,
|
||||
ENTITIES_FETCH_FAIL,
|
||||
EntityAction,
|
||||
ENTITIES_INVALIDATE_LIST,
|
||||
ENTITIES_INCREMENT,
|
||||
} from './actions';
|
||||
import { createCache, createList, updateStore, updateList } from './utils';
|
||||
|
||||
import type { DeleteEntitiesOpts } from './actions';
|
||||
import type { Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
/** Entity reducer state. */
|
||||
interface State {
|
||||
[entityType: string]: EntityCache | undefined
|
||||
}
|
||||
|
||||
/** Import entities into the cache. */
|
||||
const importEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
entities: Entity[],
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
): State => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
cache.store = updateStore(cache.store, entities);
|
||||
|
||||
if (typeof listKey === 'string') {
|
||||
let list = cache.lists[listKey] ?? createList();
|
||||
|
||||
if (overwrite) {
|
||||
list.ids = new Set();
|
||||
}
|
||||
|
||||
list = updateList(list, entities, pos);
|
||||
|
||||
if (newState) {
|
||||
list.state = newState;
|
||||
}
|
||||
|
||||
cache.lists[listKey] = list;
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
ids: Iterable<string>,
|
||||
opts: DeleteEntitiesOpts,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
|
||||
for (const id of ids) {
|
||||
delete cache.store[id];
|
||||
|
||||
if (!opts?.preserveLists) {
|
||||
for (const list of Object.values(cache.lists)) {
|
||||
if (list) {
|
||||
list.ids.delete(id);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
list.state.totalCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const dismissEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
ids: Iterable<string>,
|
||||
listKey: string,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
const list = cache.lists[listKey];
|
||||
|
||||
if (list) {
|
||||
for (const id of ids) {
|
||||
list.ids.delete(id);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
list.state.totalCount--;
|
||||
}
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const incrementEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
listKey: string,
|
||||
diff: number,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
const list = cache.lists[listKey];
|
||||
|
||||
if (typeof list?.state?.totalCount === 'number') {
|
||||
list.state.totalCount += diff;
|
||||
draft[entityType] = cache;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setFetching = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
listKey: string | undefined,
|
||||
isFetching: boolean,
|
||||
error?: any,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
|
||||
if (typeof listKey === 'string') {
|
||||
const list = cache.lists[listKey] ?? createList();
|
||||
list.state.fetching = isFetching;
|
||||
list.state.error = error;
|
||||
cache.lists[listKey] = list;
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const invalidateEntityList = (state: State, entityType: string, listKey: string) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
const list = cache.lists[listKey] ?? createList();
|
||||
list.state.invalid = true;
|
||||
});
|
||||
};
|
||||
|
||||
/** Stores various entity data and lists in a one reducer. */
|
||||
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||
switch (action.type) {
|
||||
case ENTITIES_IMPORT:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos);
|
||||
case ENTITIES_DELETE:
|
||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
||||
case ENTITIES_DISMISS:
|
||||
return dismissEntities(state, action.entityType, action.ids, action.listKey);
|
||||
case ENTITIES_INCREMENT:
|
||||
return incrementEntities(state, action.entityType, action.listKey, action.diff);
|
||||
case ENTITIES_FETCH_SUCCESS:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite);
|
||||
case ENTITIES_FETCH_REQUEST:
|
||||
return setFetching(state, action.entityType, action.listKey, true);
|
||||
case ENTITIES_FETCH_FAIL:
|
||||
return setFetching(state, action.entityType, action.listKey, false, action.error);
|
||||
case ENTITIES_INVALIDATE_LIST:
|
||||
return invalidateEntityList(state, action.entityType, action.listKey);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer;
|
||||
export type { State };
|
|
@ -1,60 +0,0 @@
|
|||
/** A Mastodon API entity. */
|
||||
interface Entity {
|
||||
/** Unique ID for the entity (usually the primary key in the database). */
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Store of entities by ID. */
|
||||
interface EntityStore<TEntity extends Entity = Entity> {
|
||||
[id: string]: TEntity | undefined
|
||||
}
|
||||
|
||||
/** List of entity IDs and fetch state. */
|
||||
interface EntityList {
|
||||
/** Set of entity IDs in this list. */
|
||||
ids: Set<string>
|
||||
/** Server state for this entity list. */
|
||||
state: EntityListState
|
||||
}
|
||||
|
||||
/** Fetch state for an entity list. */
|
||||
interface EntityListState {
|
||||
/** Next URL for pagination, if any. */
|
||||
next: string | undefined
|
||||
/** Previous URL for pagination, if any. */
|
||||
prev: string | undefined
|
||||
/** Total number of items according to the API. */
|
||||
totalCount: number | undefined
|
||||
/** Error returned from the API, if any. */
|
||||
error: any
|
||||
/** Whether data has already been fetched */
|
||||
fetched: boolean
|
||||
/** Whether data for this list is currently being fetched. */
|
||||
fetching: boolean
|
||||
/** Date of the last API fetch for this list. */
|
||||
lastFetchedAt: Date | undefined
|
||||
/** Whether the entities should be refetched on the next component mount. */
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
/** Cache data pertaining to a paritcular entity type.. */
|
||||
interface EntityCache<TEntity extends Entity = Entity> {
|
||||
/** Map of entities of this type. */
|
||||
store: EntityStore<TEntity>
|
||||
/** Lists of entity IDs for a particular purpose. */
|
||||
lists: {
|
||||
[listKey: string]: EntityList | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether to import items at the start or end of the list. */
|
||||
type ImportPosition = 'start' | 'end'
|
||||
|
||||
export {
|
||||
Entity,
|
||||
EntityStore,
|
||||
EntityList,
|
||||
EntityListState,
|
||||
EntityCache,
|
||||
ImportPosition,
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
import type { Entity, EntityStore, EntityList, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
/** Insert the entities into the store. */
|
||||
const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
|
||||
return entities.reduce<EntityStore>((store, entity) => {
|
||||
store[entity.id] = entity;
|
||||
return store;
|
||||
}, { ...store });
|
||||
};
|
||||
|
||||
/** Update the list with new entity IDs. */
|
||||
const updateList = (list: EntityList, entities: Entity[], pos: ImportPosition = 'end'): EntityList => {
|
||||
const newIds = entities.map(entity => entity.id);
|
||||
const oldIds = Array.from(list.ids);
|
||||
const ids = new Set(pos === 'start' ? [...newIds, ...oldIds] : [...oldIds, ...newIds]);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
const sizeDiff = ids.size - list.ids.size;
|
||||
list.state.totalCount += sizeDiff;
|
||||
}
|
||||
|
||||
return {
|
||||
...list,
|
||||
ids,
|
||||
};
|
||||
};
|
||||
|
||||
/** Create an empty entity cache. */
|
||||
const createCache = (): EntityCache => ({
|
||||
store: {},
|
||||
lists: {},
|
||||
});
|
||||
|
||||
/** Create an empty entity list. */
|
||||
const createList = (): EntityList => ({
|
||||
ids: new Set(),
|
||||
state: createListState(),
|
||||
});
|
||||
|
||||
/** Create an empty entity list state. */
|
||||
const createListState = (): EntityListState => ({
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: 0,
|
||||
error: null,
|
||||
fetched: false,
|
||||
fetching: false,
|
||||
lastFetchedAt: undefined,
|
||||
invalid: false,
|
||||
});
|
||||
|
||||
export {
|
||||
updateStore,
|
||||
updateList,
|
||||
createCache,
|
||||
createList,
|
||||
createListState,
|
||||
};
|
|
@ -1,128 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormattedDate, FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { deleteAnnouncement, fetchAdminAnnouncements, initAnnouncementModal } from 'soapbox/actions/admin';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { Announcement as AnnouncementEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.announcements', defaultMessage: 'Announcements' },
|
||||
deleteConfirm: { id: 'confirmations.admin.delete_announcement.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.admin.delete_announcement.heading', defaultMessage: 'Delete announcement' },
|
||||
deleteMessage: { id: 'confirmations.admin.delete_announcement.message', defaultMessage: 'Are you sure you want to delete the announcement?' },
|
||||
});
|
||||
|
||||
interface IAnnouncement {
|
||||
announcement: AnnouncementEntity
|
||||
}
|
||||
|
||||
const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleEditAnnouncement = (announcement: AnnouncementEntity) => () => {
|
||||
dispatch(initAnnouncementModal(announcement));
|
||||
};
|
||||
|
||||
const handleDeleteAnnouncement = (id: string) => () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteAnnouncement(id)),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={announcement.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||
<Stack space={2}>
|
||||
<Text dangerouslySetInnerHTML={{ __html: announcement.contentHtml }} />
|
||||
{(announcement.starts_at || announcement.ends_at || announcement.all_day) && (
|
||||
<HStack space={2} wrap>
|
||||
{announcement.starts_at && (
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.announcements.starts_at' defaultMessage='Starts at:' />
|
||||
</Text>
|
||||
{' '}
|
||||
<FormattedDate value={announcement.starts_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||
</Text>
|
||||
)}
|
||||
{announcement.ends_at && (
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.announcements.ends_at' defaultMessage='Ends at:' />
|
||||
</Text>
|
||||
{' '}
|
||||
<FormattedDate value={announcement.ends_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||
</Text>
|
||||
)}
|
||||
{announcement.all_day && (
|
||||
<Text weight='medium' size='sm'>
|
||||
<FormattedMessage id='admin.announcements.all_day' defaultMessage='All day' />
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
<HStack justifyContent='end' space={2}>
|
||||
<Button theme='primary' onClick={handleEditAnnouncement(announcement)}>
|
||||
<FormattedMessage id='admin.announcements.edit' defaultMessage='Edit' />
|
||||
</Button>
|
||||
<Button theme='primary' onClick={handleDeleteAnnouncement(announcement.id)}>
|
||||
<FormattedMessage id='admin.announcements.delete' defaultMessage='Delete' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Announcements: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const announcements = useAppSelector((state) => state.admin_announcements.items);
|
||||
const isLoading = useAppSelector((state) => state.admin_announcements.isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchAdminAnnouncements());
|
||||
}, []);
|
||||
|
||||
const handleCreateAnnouncement = () => {
|
||||
dispatch(initAnnouncementModal());
|
||||
};
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.admin.announcements' defaultMessage='There are no announcements yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack className='gap-4'>
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end'
|
||||
icon={require('@tabler/icons/plus.svg')}
|
||||
onClick={handleCreateAnnouncement}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='admin.announcements.action' defaultMessage='Create announcement' />
|
||||
</Button>
|
||||
<ScrollableList
|
||||
scrollKey='announcements'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !announcements.count()}
|
||||
>
|
||||
{announcements.map((announcement) => (
|
||||
<Announcement key={announcement.id} announcement={announcement} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcements;
|
|
@ -1,70 +0,0 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column, Input } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.users', defaultMessage: 'Users' },
|
||||
empty: { id: 'admin.user_index.empty', defaultMessage: 'No users found.' },
|
||||
searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' },
|
||||
});
|
||||
|
||||
const UserIndex: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!isLoading) dispatch(expandUserIndex());
|
||||
};
|
||||
|
||||
const updateQuery = useCallback(debounce(() => {
|
||||
dispatch(fetchUserIndex());
|
||||
}, 900, { leading: true }), []);
|
||||
|
||||
const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
dispatch(setUserIndexQuery(e.target.value));
|
||||
updateQuery();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateQuery();
|
||||
}, []);
|
||||
|
||||
|
||||
const hasMore = items.count() < total && !!next;
|
||||
|
||||
const showLoading = isLoading && items.isEmpty();
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
/>
|
||||
<ScrollableList
|
||||
scrollKey='user-index'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={intl.formatMessage(messages.empty)}
|
||||
className='mt-4'
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{items.map(id =>
|
||||
<AccountContainer key={id} id={id} withDate />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserIndex;
|
|
@ -1,76 +0,0 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../../../jest/test-helpers';
|
||||
import ChatMessageReaction from '../chat-message-reaction';
|
||||
|
||||
const emojiReaction = ({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: false,
|
||||
});
|
||||
|
||||
describe('<ChatMessageReaction />', () => {
|
||||
it('renders properly', () => {
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={jest.fn()}
|
||||
onRemoveReaction={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img').getAttribute('alt')).toEqual(emojiReaction.name);
|
||||
expect(screen.getByRole('button')).toHaveTextContent(String(emojiReaction.count));
|
||||
});
|
||||
|
||||
it('triggers the "onAddReaction" function', async () => {
|
||||
const onAddFn = jest.fn();
|
||||
const onRemoveFn = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={onAddFn}
|
||||
onRemoveReaction={onRemoveFn}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
// add function triggered
|
||||
expect(onAddFn).toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('triggers the "onRemoveReaction" function', async () => {
|
||||
const onAddFn = jest.fn();
|
||||
const onRemoveFn = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: true,
|
||||
})}
|
||||
onAddReaction={onAddFn}
|
||||
onRemoveReaction={onRemoveFn}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
// remove function triggered
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).toBeCalled();
|
||||
});
|
||||
});
|
|
@ -1,56 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { Portal } from 'soapbox/components/ui';
|
||||
import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector';
|
||||
|
||||
interface IChatMessageReactionWrapper {
|
||||
onOpen(isOpen: boolean): void
|
||||
onSelect(emoji: string): void
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji Reaction Selector
|
||||
*/
|
||||
function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) {
|
||||
const { onOpen, onSelect, children } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const handleSelect = (emoji: string) => {
|
||||
onSelect(emoji);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => setIsOpen((prevValue) => !prevValue);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{React.cloneElement(children, {
|
||||
ref: setReferenceElement,
|
||||
onClick: onToggleVisibility,
|
||||
})}
|
||||
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<EmojiSelector
|
||||
visible={isOpen}
|
||||
referenceElement={referenceElement}
|
||||
onReact={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
offsetOptions={{ mainAxis: 12, crossAxis: -10 }}
|
||||
all={false}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessageReactionWrapper;
|
|
@ -1,45 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { EmojiReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IChatMessageReaction {
|
||||
emojiReaction: EmojiReaction
|
||||
onRemoveReaction(emoji: string): void
|
||||
onAddReaction(emoji: string): void
|
||||
}
|
||||
|
||||
const ChatMessageReaction = (props: IChatMessageReaction) => {
|
||||
const { emojiReaction, onAddReaction, onRemoveReaction } = props;
|
||||
|
||||
const isAlreadyReacted = emojiReaction.me;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isAlreadyReacted) {
|
||||
onRemoveReaction(emojiReaction.name);
|
||||
} else {
|
||||
onAddReaction(emojiReaction.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleClick}
|
||||
className={
|
||||
clsx({
|
||||
'w-12 rounded-lg flex justify-between text-sm border items-center border-solid text-gray-700 dark:text-gray-600 px-2 py-1 space-x-1.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-800 whitespace-nowrap': true,
|
||||
'border-primary-500 dark:border-primary-400': emojiReaction.me,
|
||||
'border-gray-300 dark:border-gray-800': !emojiReaction.me,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: emojify(emojiReaction.name) }} />
|
||||
<Text tag='span' weight='medium' size='sm'>{emojiReaction.count}</Text>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageReaction;
|
|
@ -1,393 +0,0 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import clsx from 'clsx';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import { escape } from 'lodash';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { initReport, ReportableEntities } from 'soapbox/actions/reports';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { stripHTML } from 'soapbox/utils/html';
|
||||
import { onlyEmoji } from 'soapbox/utils/rich-content';
|
||||
|
||||
import ChatMessageReaction from './chat-message-reaction';
|
||||
import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper';
|
||||
|
||||
import type { Menu as IMenu } from 'soapbox/components/dropdown-menu';
|
||||
import type { IMediaGallery } from 'soapbox/components/media-gallery';
|
||||
import type { Account, ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
||||
|
||||
const messages = defineMessages({
|
||||
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
|
||||
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
|
||||
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
|
||||
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
||||
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
|
||||
});
|
||||
|
||||
const BIG_EMOJI_LIMIT = 3;
|
||||
|
||||
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
}, ImmutableMap());
|
||||
|
||||
const parsePendingContent = (content: string) => {
|
||||
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
||||
};
|
||||
|
||||
const parseContent = (chatMessage: ChatMessageEntity) => {
|
||||
const content = chatMessage.content || '';
|
||||
const pending = chatMessage.pending;
|
||||
const deleting = chatMessage.deleting;
|
||||
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
|
||||
const emojiMap = makeEmojiMap(chatMessage);
|
||||
return emojify(formatted, emojiMap.toJS());
|
||||
};
|
||||
|
||||
interface IChatMessage {
|
||||
chat: IChat
|
||||
chatMessage: ChatMessageEntity
|
||||
}
|
||||
|
||||
const ChatMessage = (props: IChatMessage) => {
|
||||
const { chat, chatMessage } = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
const intl = useIntl();
|
||||
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const { createReaction, deleteChatMessage, deleteReaction } = useChatActions(chat.id);
|
||||
|
||||
const [isReactionSelectorOpen, setIsReactionSelectorOpen] = useState<boolean>(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
|
||||
},
|
||||
});
|
||||
|
||||
const content = parseContent(chatMessage);
|
||||
const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
|
||||
const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
|
||||
const isMyMessage = chatMessage.account_id === me;
|
||||
|
||||
// did this occur before this time?
|
||||
const isRead = isMyMessage
|
||||
&& lastReadMessageTimestamp
|
||||
&& lastReadMessageTimestamp >= new Date(chatMessage.created_at);
|
||||
|
||||
const isOnlyEmoji = useMemo(() => {
|
||||
const hiddenEl = document.createElement('div');
|
||||
hiddenEl.innerHTML = content;
|
||||
return onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false);
|
||||
}, []);
|
||||
|
||||
const emojiReactionRows = useMemo(() => {
|
||||
if (!chatMessage.emoji_reactions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chatMessage.emoji_reactions.reduce((rows: any, key: any, index) => {
|
||||
return (index % 4 === 0 ? rows.push([key])
|
||||
: rows[rows.length - 1].push(key)) && rows;
|
||||
}, []);
|
||||
}, [chatMessage.emoji_reactions]);
|
||||
|
||||
const onOpenMedia = (media: any, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
};
|
||||
|
||||
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
|
||||
if (!chatMessage.media_attachments.size) return null;
|
||||
|
||||
return (
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: React.FC<IMediaGallery>) => (
|
||||
<Component
|
||||
className={clsx({
|
||||
'rounded-br-sm': isMyMessage && content,
|
||||
'rounded-bl-sm': !isMyMessage && content,
|
||||
})}
|
||||
media={chatMessage.media_attachments}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyText = (chatMessage: ChatMessageEntity) => {
|
||||
if (navigator.clipboard) {
|
||||
const text = stripHTML(chatMessage.content);
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
};
|
||||
const setBubbleRef = (c: HTMLDivElement) => {
|
||||
if (!c) return;
|
||||
const links = c.querySelectorAll('a[rel="ugc"]');
|
||||
|
||||
links.forEach(link => {
|
||||
link.classList.add('chat-link');
|
||||
link.setAttribute('rel', 'ugc nofollow noopener');
|
||||
link.setAttribute('target', '_blank');
|
||||
});
|
||||
};
|
||||
|
||||
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
|
||||
return intl.formatDate(new Date(chatMessage.created_at), {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const menu: IMenu = [];
|
||||
|
||||
if (navigator.clipboard && chatMessage.content) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.copy),
|
||||
action: () => handleCopyText(chatMessage),
|
||||
icon: require('@tabler/icons/copy.svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isMyMessage) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.delete),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
} else {
|
||||
if (features.reportChats) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report),
|
||||
action: () => dispatch(initReport(ReportableEntities.CHAT_MESSAGE, normalizeAccount(chat.account) as Account, { chatMessage })),
|
||||
icon: require('@tabler/icons/flag.svg'),
|
||||
});
|
||||
}
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deleteForMe),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
return menu;
|
||||
}, [chatMessage, chat]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'group relative px-4 py-2 hover:bg-gray-200/40 dark:hover:bg-gray-800/40': true,
|
||||
'bg-gray-200/40 dark:bg-gray-800/40': isMenuOpen || isReactionSelectorOpen,
|
||||
})
|
||||
}
|
||||
data-testid='chat-message'
|
||||
>
|
||||
<div
|
||||
className={
|
||||
clsx({
|
||||
'p-1 flex items-center space-x-0.5 z-10 absolute opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700': true,
|
||||
'top-2 right-2': !isMyMessage,
|
||||
'top-2 left-2': isMyMessage,
|
||||
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
|
||||
})
|
||||
}
|
||||
>
|
||||
{features.chatEmojiReactions && (
|
||||
<ChatMessageReactionWrapper
|
||||
onOpen={setIsReactionSelectorOpen}
|
||||
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
|
||||
>
|
||||
<button
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className={clsx({
|
||||
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500 focus:ring-0': true,
|
||||
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/mood-smile.svg')}
|
||||
className='h-4 w-4'
|
||||
/>
|
||||
</button>
|
||||
</ChatMessageReactionWrapper>
|
||||
)}
|
||||
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<button
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className={clsx({
|
||||
'p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-md text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500 focus:ring-0': true,
|
||||
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
|
||||
})}
|
||||
data-testid='chat-message-menu'
|
||||
>
|
||||
<Icon
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
className='h-4 w-4'
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Stack
|
||||
space={1.5}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
className={clsx({
|
||||
'opacity-50': chatMessage.pending,
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
space={0.5}
|
||||
className={clsx({
|
||||
'max-w-[85%]': true,
|
||||
'flex-1': !!chatMessage.media_attachments.size,
|
||||
'order-3': isMyMessage,
|
||||
'order-1': !isMyMessage,
|
||||
})}
|
||||
alignItems={isMyMessage ? 'end' : 'start'}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
|
||||
{content && (
|
||||
<HStack alignItems='bottom' className='max-w-full'>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
clsx({
|
||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
|
||||
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
|
||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||
'bg-primary-500 text-white': isMyMessage,
|
||||
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
|
||||
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
size='sm'
|
||||
theme='inherit'
|
||||
className='break-word-nested'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
{(chatMessage.emoji_reactions?.length) ? (
|
||||
<div
|
||||
className={clsx({
|
||||
'space-y-1': true,
|
||||
'ml-auto': isMyMessage,
|
||||
'mr-auto': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => (
|
||||
<HStack
|
||||
key={idx}
|
||||
className={
|
||||
clsx({
|
||||
'flex items-center gap-1': true,
|
||||
'flex-row-reverse': isMyMessage,
|
||||
})
|
||||
}
|
||||
>
|
||||
{emojiReactionRow.map((emojiReaction: any, idx: number) => (
|
||||
<ChatMessageReaction
|
||||
key={idx}
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
|
||||
onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HStack
|
||||
alignItems='center'
|
||||
space={2}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx({
|
||||
'text-right': isMyMessage,
|
||||
'order-2': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
<span className='flex items-center space-x-1.5'>
|
||||
<Text theme='muted' size='xs'>
|
||||
{intl.formatTime(chatMessage.created_at)}
|
||||
</Text>
|
||||
|
||||
{(isMyMessage && features.chatsReadReceipts) ? (
|
||||
<>
|
||||
{isRead ? (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
strokeWidth={3}
|
||||
className='h-2.5 w-2.5'
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
strokeWidth={3}
|
||||
className='h-2.5 w-2.5'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
|
@ -1,18 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { ProgressBar } from 'soapbox/components/ui';
|
||||
|
||||
interface IChatPendingUpload {
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** Displays a loading thumbnail for an upload in the chat composer. */
|
||||
const ChatPendingUpload: React.FC<IChatPendingUpload> = ({ progress }) => {
|
||||
return (
|
||||
<div className='relative isolate inline-flex h-24 w-24 items-center justify-center overflow-hidden rounded-lg bg-gray-200 p-4 dark:bg-primary-900'>
|
||||
<ProgressBar progress={progress} size='sm' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPendingUpload;
|
|
@ -1,72 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Textarea } from 'soapbox/components/ui';
|
||||
import { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
import ChatPendingUpload from './chat-pending-upload';
|
||||
import ChatUpload from './chat-upload';
|
||||
|
||||
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
||||
attachments?: Attachment[]
|
||||
onDeleteAttachment?: (i: number) => void
|
||||
uploadCount?: number
|
||||
uploadProgress?: number
|
||||
}
|
||||
|
||||
/** Custom textarea for chats. */
|
||||
const ChatTextarea: React.FC<IChatTextarea> = React.forwardRef(({
|
||||
attachments,
|
||||
onDeleteAttachment,
|
||||
uploadCount = 0,
|
||||
uploadProgress = 0,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const isUploading = uploadCount > 0;
|
||||
|
||||
const handleDeleteAttachment = (i: number) => {
|
||||
return () => {
|
||||
if (onDeleteAttachment) {
|
||||
onDeleteAttachment(i);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
block
|
||||
w-full
|
||||
rounded-md border border-gray-400
|
||||
bg-white text-gray-900
|
||||
shadow-sm placeholder:text-gray-600
|
||||
focus-within:border-primary-500
|
||||
focus-within:ring-1 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-800
|
||||
dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500
|
||||
dark:focus-within:ring-primary-500 sm:text-sm
|
||||
`}
|
||||
>
|
||||
{(!!attachments?.length || isUploading) && (
|
||||
<HStack className='-ml-2 -mt-2 p-3 pb-0' wrap>
|
||||
{attachments?.map((attachment, i) => (
|
||||
<div className='ml-2 mt-2 flex'>
|
||||
<ChatUpload
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onDelete={handleDeleteAttachment(i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from(Array(uploadCount)).map(() => (
|
||||
<div className='ml-2 mt-2 flex'>
|
||||
<ChatPendingUpload progress={uploadProgress} />
|
||||
</div>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Textarea ref={ref} theme='transparent' {...rest} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChatTextarea;
|
|
@ -1,56 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
||||
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
const defaultIcon = require('@tabler/icons/paperclip.svg');
|
||||
|
||||
interface IChatUploadPreview {
|
||||
className?: string
|
||||
attachment: Attachment
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a generic preview for an upload depending on its media type.
|
||||
* It fills its container and is expected to be sized by its parent.
|
||||
*/
|
||||
const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment }) => {
|
||||
const mimeType = attachment.pleroma.get('mime_type') as string | undefined;
|
||||
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
case 'gifv':
|
||||
return (
|
||||
<img
|
||||
className='pointer-events-none h-full w-full object-cover'
|
||||
src={attachment.preview_url}
|
||||
alt=''
|
||||
/>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
className='pointer-events-none h-full w-full object-cover'
|
||||
src={attachment.preview_url}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls={false}
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className='pointer-events-none flex h-full w-full items-center justify-center'>
|
||||
<Icon
|
||||
className='mx-auto my-12 h-16 w-16 text-gray-800 dark:text-gray-200'
|
||||
src={MIMETYPE_ICONS[mimeType || ''] || defaultIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ChatUploadPreview;
|
|
@ -1,66 +0,0 @@
|
|||
import clsx from 'clsx';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import ChatUploadPreview from './chat-upload-preview';
|
||||
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
interface IChatUpload {
|
||||
attachment: Attachment
|
||||
onDelete?(): void
|
||||
}
|
||||
|
||||
/** An attachment uploaded to the chat composer, before sending. */
|
||||
const ChatUpload: React.FC<IChatUpload> = ({ attachment, onDelete }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const clickable = attachment.type !== 'unknown';
|
||||
|
||||
const handleOpenModal = () => {
|
||||
dispatch(openModal('MEDIA', { media: ImmutableList.of(attachment), index: 0 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative isolate inline-block h-24 w-24 overflow-hidden rounded-lg bg-gray-200 dark:bg-primary-900'>
|
||||
<Blurhash hash={attachment.blurhash} className='absolute inset-0 -z-10 h-full w-full' />
|
||||
|
||||
<div className='absolute right-[6px] top-[6px]'>
|
||||
<RemoveButton onClick={onDelete} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clickable ? handleOpenModal : undefined}
|
||||
className={clsx('h-full w-full', { 'cursor-zoom-in': clickable, 'cursor-default': !clickable })}
|
||||
>
|
||||
<ChatUploadPreview attachment={attachment} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IRemoveButton {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
/** Floating button to remove an attachment. */
|
||||
const RemoveButton: React.FC<IRemoveButton> = ({ onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
className='flex h-5 w-5 items-center justify-center rounded-full bg-secondary-500 p-1'
|
||||
>
|
||||
<Icon
|
||||
className='h-3 w-3 text-white'
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatUpload;
|
|
@ -1,42 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Link from 'soapbox/components/link';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { Group } from 'soapbox/schemas';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
interface IReplyGroupIndicator {
|
||||
composeId: string
|
||||
}
|
||||
|
||||
const ReplyGroupIndicator = (props: IReplyGroupIndicator) => {
|
||||
const { composeId } = props;
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector((state) => getStatus(state, { id: state.compose.get(composeId)?.in_reply_to! }));
|
||||
const group = status?.group as Group;
|
||||
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='compose.reply_group_indicator.message'
|
||||
defaultMessage='Posting to {groupLink}'
|
||||
values={{
|
||||
groupLink: <Link
|
||||
to={`/group/${group.slug}`}
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyGroupIndicator;
|
|
@ -1,258 +0,0 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React, { useEffect, useState, useLayoutEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { useEmoji } from 'soapbox/actions/emojis';
|
||||
import { changeSetting } from 'soapbox/actions/settings';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
import { RootState } from 'soapbox/store';
|
||||
|
||||
import { buildCustomEmojis } from '../../emoji';
|
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||
|
||||
import type { Emoji, CustomEmoji, NativeEmoji } from 'soapbox/features/emoji';
|
||||
|
||||
let EmojiPicker: any; // load asynchronously
|
||||
|
||||
export const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_pick: { id: 'emoji_button.pick', defaultMessage: 'Pick an emoji…' },
|
||||
emoji_oh_no: { id: 'emoji_button.oh_no', defaultMessage: 'Oh no!' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
|
||||
emoji_add_custom: { id: 'emoji_button.add_custom', defaultMessage: 'Add custom emoji' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
skins_choose: { id: 'emoji_button.skins_choose', defaultMessage: 'Choose default skin tone' },
|
||||
skins_1: { id: 'emoji_button.skins_1', defaultMessage: 'Default' },
|
||||
skins_2: { id: 'emoji_button.skins_2', defaultMessage: 'Light' },
|
||||
skins_3: { id: 'emoji_button.skins_3', defaultMessage: 'Medium-Light' },
|
||||
skins_4: { id: 'emoji_button.skins_4', defaultMessage: 'Medium' },
|
||||
skins_5: { id: 'emoji_button.skins_5', defaultMessage: 'Medium-Dark' },
|
||||
skins_6: { id: 'emoji_button.skins_6', defaultMessage: 'Dark' },
|
||||
});
|
||||
|
||||
export interface IEmojiPickerDropdown {
|
||||
onPickEmoji?: (emoji: Emoji) => void
|
||||
condensed?: boolean
|
||||
withCustom?: boolean
|
||||
visible: boolean
|
||||
setVisible: (value: boolean) => void
|
||||
update: (() => any) | null
|
||||
}
|
||||
|
||||
const perLine = 8;
|
||||
const lines = 2;
|
||||
|
||||
const DEFAULTS = [
|
||||
'+1',
|
||||
'grinning',
|
||||
'kissing_heart',
|
||||
'heart_eyes',
|
||||
'laughing',
|
||||
'stuck_out_tongue_winking_eye',
|
||||
'sweat_smile',
|
||||
'joy',
|
||||
'yum',
|
||||
'disappointed',
|
||||
'thinking_face',
|
||||
'weary',
|
||||
'sob',
|
||||
'sunglasses',
|
||||
'heart',
|
||||
'ok_hand',
|
||||
];
|
||||
|
||||
export const getFrequentlyUsedEmojis = createSelector([
|
||||
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
|
||||
], (emojiCounters: ImmutableMap<string, number>) => {
|
||||
let emojis = emojiCounters
|
||||
.keySeq()
|
||||
.sort((a, b) => emojiCounters.get(a)! - emojiCounters.get(b)!)
|
||||
.reverse()
|
||||
.slice(0, perLine * lines)
|
||||
.toArray();
|
||||
|
||||
if (emojis.length < DEFAULTS.length) {
|
||||
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
|
||||
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
|
||||
}
|
||||
|
||||
return emojis;
|
||||
});
|
||||
|
||||
const getCustomEmojis = createSelector([
|
||||
(state: RootState) => state.custom_emojis,
|
||||
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
|
||||
const aShort = a.get('shortcode')!.toLowerCase();
|
||||
const bShort = b.get('shortcode')!.toLowerCase();
|
||||
|
||||
if (aShort < bShort) {
|
||||
return -1;
|
||||
} else if (aShort > bShort) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}));
|
||||
|
||||
// Fixes render bug where popover has a delayed position update
|
||||
const RenderAfter = ({ children, update }: any) => {
|
||||
const [nextTick, setNextTick] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setNextTick(true);
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (nextTick) {
|
||||
update();
|
||||
}
|
||||
}, [nextTick, update]);
|
||||
|
||||
return nextTick ? children : null;
|
||||
};
|
||||
|
||||
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({
|
||||
onPickEmoji, visible, setVisible, update, withCustom = true,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const settings = useSettings();
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const userTheme = settings.get('themeMode');
|
||||
const theme = (userTheme === 'dark' || userTheme === 'light') ? userTheme : 'auto';
|
||||
|
||||
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
|
||||
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePick = (emoji: any) => {
|
||||
setVisible(false);
|
||||
|
||||
let pickedEmoji: Emoji;
|
||||
|
||||
if (emoji.native) {
|
||||
pickedEmoji = {
|
||||
id: emoji.id,
|
||||
colons: emoji.shortcodes,
|
||||
custom: false,
|
||||
native: emoji.native,
|
||||
unified: emoji.unified,
|
||||
} as NativeEmoji;
|
||||
} else {
|
||||
pickedEmoji = {
|
||||
id: emoji.id,
|
||||
colons: emoji.shortcodes,
|
||||
custom: true,
|
||||
imageUrl: emoji.src,
|
||||
} as CustomEmoji;
|
||||
}
|
||||
|
||||
dispatch(useEmoji(pickedEmoji)); // eslint-disable-line react-hooks/rules-of-hooks
|
||||
|
||||
if (onPickEmoji) {
|
||||
onPickEmoji(pickedEmoji);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkinTone = (skinTone: string) => {
|
||||
dispatch(changeSetting(['skinTone'], skinTone));
|
||||
};
|
||||
|
||||
const getI18n = () => {
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
pick: intl.formatMessage(messages.emoji_pick),
|
||||
search_no_results_1: intl.formatMessage(messages.emoji_oh_no),
|
||||
search_no_results_2: intl.formatMessage(messages.emoji_not_found),
|
||||
add_custom: intl.formatMessage(messages.emoji_add_custom),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
frequent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
skins: {
|
||||
choose: intl.formatMessage(messages.skins_choose),
|
||||
1: intl.formatMessage(messages.skins_1),
|
||||
2: intl.formatMessage(messages.skins_2),
|
||||
3: intl.formatMessage(messages.skins_3),
|
||||
4: intl.formatMessage(messages.skins_4),
|
||||
5: intl.formatMessage(messages.skins_5),
|
||||
6: intl.formatMessage(messages.skins_6),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// fix scrolling focus issue
|
||||
if (visible) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (!EmojiPicker) {
|
||||
setLoading(true);
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => () => {
|
||||
document.body.style.overflow = '';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
visible ? (
|
||||
<RenderAfter update={update}>
|
||||
{!loading && (
|
||||
<EmojiPicker
|
||||
custom={withCustom ? [{ emojis: buildCustomEmojis(customEmojis) }] : undefined}
|
||||
title={title}
|
||||
onEmojiSelect={handlePick}
|
||||
recent={frequentlyUsedEmojis}
|
||||
perLine={8}
|
||||
skin={handleSkinTone}
|
||||
emojiSize={22}
|
||||
emojiButtonSize={34}
|
||||
set='twitter'
|
||||
theme={theme}
|
||||
i18n={getI18n()}
|
||||
skinTonePosition='search'
|
||||
previewPosition='none'
|
||||
/>
|
||||
)}
|
||||
</RenderAfter>
|
||||
) : null
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerDropdown;
|
|
@ -1,30 +0,0 @@
|
|||
import { Picker as EmojiPicker } from 'emoji-mart';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
import { joinPublicPath } from 'soapbox/utils/static';
|
||||
|
||||
import data from '../data';
|
||||
|
||||
const getSpritesheetURL = (set: string) => {
|
||||
return require('emoji-datasource/img/twitter/sheets/32.png');
|
||||
};
|
||||
|
||||
const getImageURL = (set: string, name: string) => {
|
||||
return joinPublicPath(`/packs/emoji/${name}.svg`);
|
||||
};
|
||||
|
||||
const Picker = (props: any) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const input = { ...props, data, ref, getImageURL, getSpritesheetURL };
|
||||
|
||||
new EmojiPicker(input);
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} />;
|
||||
};
|
||||
|
||||
export {
|
||||
Picker,
|
||||
};
|
|
@ -1,79 +0,0 @@
|
|||
import { useFloating, shift } from '@floating-ui/react';
|
||||
import clsx from 'clsx';
|
||||
import React, { KeyboardEvent, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { IconButton } from 'soapbox/components/ui';
|
||||
import { useClickOutside } from 'soapbox/hooks';
|
||||
|
||||
import EmojiPickerDropdown, { IEmojiPickerDropdown } from '../components/emoji-picker-dropdown';
|
||||
|
||||
export const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
});
|
||||
|
||||
const EmojiPickerDropdownContainer = (
|
||||
props: Pick<IEmojiPickerDropdown, 'onPickEmoji' | 'condensed' | 'withCustom'>,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const { x, y, strategy, refs, update } = useFloating<HTMLButtonElement>({
|
||||
middleware: [shift()],
|
||||
});
|
||||
|
||||
useClickOutside(refs, () => {
|
||||
setVisible(false);
|
||||
});
|
||||
|
||||
const handleToggle = (e: MouseEvent | KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
setVisible(!visible);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<IconButton
|
||||
className={clsx({
|
||||
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
|
||||
})}
|
||||
ref={refs.setReference}
|
||||
src={require('@tabler/icons/mood-happy.svg')}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-expanded={visible}
|
||||
role='button'
|
||||
onClick={handleToggle as any}
|
||||
onKeyDown={handleToggle as React.KeyboardEventHandler<HTMLButtonElement>}
|
||||
tabIndex={0}
|
||||
/>
|
||||
|
||||
{createPortal(
|
||||
<div
|
||||
className='z-[101]'
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? 0,
|
||||
left: x ?? 0,
|
||||
width: 'max-content',
|
||||
}}
|
||||
>
|
||||
<EmojiPickerDropdown
|
||||
visible={visible}
|
||||
setVisible={setVisible}
|
||||
update={update}
|
||||
{...props}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default EmojiPickerDropdownContainer;
|
|
@ -1,52 +0,0 @@
|
|||
import data from '@emoji-mart/data/sets/14/twitter.json';
|
||||
|
||||
export interface NativeEmoji {
|
||||
unified: string
|
||||
native: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface CustomEmoji {
|
||||
src: string
|
||||
}
|
||||
|
||||
export interface Emoji<T> {
|
||||
id: string
|
||||
name: string
|
||||
keywords: string[]
|
||||
skins: T[]
|
||||
version?: number
|
||||
}
|
||||
|
||||
export interface EmojiCategory {
|
||||
id: string
|
||||
emojis: string[]
|
||||
}
|
||||
|
||||
export interface EmojiMap {
|
||||
[s: string]: Emoji<NativeEmoji>
|
||||
}
|
||||
|
||||
export interface EmojiAlias {
|
||||
[s: string]: string
|
||||
}
|
||||
|
||||
export interface EmojiSheet {
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
export interface EmojiData {
|
||||
categories: EmojiCategory[]
|
||||
emojis: EmojiMap
|
||||
aliases: EmojiAlias
|
||||
sheet: EmojiSheet
|
||||
}
|
||||
|
||||
const emojiData = data as EmojiData;
|
||||
const { categories, emojis, aliases, sheet } = emojiData;
|
||||
|
||||
export { categories, emojis, aliases, sheet };
|
||||
|
||||
export default emojiData;
|
|
@ -1,228 +0,0 @@
|
|||
import split from 'graphemesplit';
|
||||
|
||||
import unicodeMapping from './mapping';
|
||||
|
||||
import type { Emoji as EmojiMart, CustomEmoji as EmojiMartCustom } from 'soapbox/features/emoji/data';
|
||||
|
||||
/*
|
||||
* TODO: Consolate emoji object types
|
||||
*
|
||||
* There are five different emoji objects currently
|
||||
* - emoji-mart's "onPickEmoji" handler
|
||||
* - emoji-mart's custom emoji types
|
||||
* - an Emoji type that is either NativeEmoji or CustomEmoji
|
||||
* - a type inside redux's `store.custom_emoji` immutablejs
|
||||
*
|
||||
* there needs to be one type for the picker handler callback
|
||||
* and one type for the emoji-mart data
|
||||
* and one type that is used everywhere that the above two are converted into
|
||||
*/
|
||||
|
||||
export interface CustomEmoji {
|
||||
id: string
|
||||
colons: string
|
||||
custom: true
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export interface NativeEmoji {
|
||||
id: string
|
||||
colons: string
|
||||
custom?: false
|
||||
unified: string
|
||||
native: string
|
||||
}
|
||||
|
||||
export type Emoji = CustomEmoji | NativeEmoji;
|
||||
|
||||
export function isCustomEmoji(emoji: Emoji): emoji is CustomEmoji {
|
||||
return (emoji as CustomEmoji).imageUrl !== undefined;
|
||||
}
|
||||
|
||||
export function isNativeEmoji(emoji: Emoji): emoji is NativeEmoji {
|
||||
return (emoji as NativeEmoji).native !== undefined;
|
||||
}
|
||||
|
||||
const isAlphaNumeric = (c: string) => {
|
||||
const code = c.charCodeAt(0);
|
||||
|
||||
if (!(code > 47 && code < 58) && // numeric (0-9)
|
||||
!(code > 64 && code < 91) && // upper alpha (A-Z)
|
||||
!(code > 96 && code < 123)) { // lower alpha (a-z)
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const validEmojiChar = (c: string) => {
|
||||
return isAlphaNumeric(c)
|
||||
|| c === '_'
|
||||
|| c === '-'
|
||||
|| c === '.';
|
||||
};
|
||||
|
||||
const convertCustom = (shortname: string, filename: string) => {
|
||||
return `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
|
||||
};
|
||||
|
||||
const convertUnicode = (c: string) => {
|
||||
const { unified, shortcode } = unicodeMapping[c];
|
||||
|
||||
return `<img draggable="false" class="emojione" alt="${c}" title=":${shortcode}:" src="/packs/emoji/${unified}.svg" />`;
|
||||
};
|
||||
|
||||
const convertEmoji = (str: string, customEmojis: any) => {
|
||||
if (str.length < 3) return str;
|
||||
if (str in customEmojis) {
|
||||
const emoji = customEmojis[str];
|
||||
const filename = emoji.static_url;
|
||||
|
||||
if (filename?.length > 0) {
|
||||
return convertCustom(str, filename);
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
export const emojifyText = (str: string, customEmojis = {}) => {
|
||||
let buf = '';
|
||||
let stack = '';
|
||||
let open = false;
|
||||
|
||||
const clearStack = () => {
|
||||
buf += stack;
|
||||
open = false;
|
||||
stack = '';
|
||||
};
|
||||
|
||||
for (let c of split(str)) {
|
||||
// convert FE0E selector to FE0F so it can be found in unimap
|
||||
if (c.codePointAt(c.length - 1) === 65038) {
|
||||
c = c.slice(0, -1) + String.fromCodePoint(65039);
|
||||
}
|
||||
|
||||
// unqualified emojis aren't in emoji-mart's mappings so we just add FEOF
|
||||
const unqualified = c + String.fromCodePoint(65039);
|
||||
|
||||
if (c in unicodeMapping) {
|
||||
if (open) { // unicode emoji inside colon
|
||||
clearStack();
|
||||
}
|
||||
|
||||
buf += convertUnicode(c);
|
||||
} else if (unqualified in unicodeMapping) {
|
||||
if (open) { // unicode emoji inside colon
|
||||
clearStack();
|
||||
}
|
||||
|
||||
buf += convertUnicode(unqualified);
|
||||
} else if (c === ':') {
|
||||
stack += ':';
|
||||
|
||||
// we see another : we convert it and clear the stack buffer
|
||||
if (open) {
|
||||
buf += convertEmoji(stack, customEmojis);
|
||||
stack = '';
|
||||
}
|
||||
|
||||
open = !open;
|
||||
} else {
|
||||
if (open) {
|
||||
stack += c;
|
||||
|
||||
// if the stack is non-null and we see invalid chars it's a string not emoji
|
||||
// so we push it to the return result and clear it
|
||||
if (!validEmojiChar(c)) {
|
||||
clearStack();
|
||||
}
|
||||
} else {
|
||||
buf += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// never found a closing colon so it's just a raw string
|
||||
if (open) {
|
||||
buf += stack;
|
||||
}
|
||||
|
||||
return buf;
|
||||
};
|
||||
|
||||
export const parseHTML = (str: string): { text: boolean, data: string }[] => {
|
||||
const tokens = [];
|
||||
let buf = '';
|
||||
let stack = '';
|
||||
let open = false;
|
||||
|
||||
for (const c of str) {
|
||||
if (c === '<') {
|
||||
if (open) {
|
||||
tokens.push({ text: true, data: stack });
|
||||
stack = '<';
|
||||
} else {
|
||||
tokens.push({ text: true, data: buf });
|
||||
stack = '<';
|
||||
open = true;
|
||||
}
|
||||
} else if (c === '>') {
|
||||
if (open) {
|
||||
open = false;
|
||||
tokens.push({ text: false, data: stack + '>' });
|
||||
stack = '';
|
||||
buf = '';
|
||||
} else {
|
||||
buf += '>';
|
||||
}
|
||||
|
||||
} else {
|
||||
if (open) {
|
||||
stack += c;
|
||||
} else {
|
||||
buf += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
tokens.push({ text: true, data: buf + stack });
|
||||
} else if (buf !== '') {
|
||||
tokens.push({ text: true, data: buf });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const emojify = (str: string, customEmojis = {}) => {
|
||||
return parseHTML(str)
|
||||
.map(({ text, data }) => {
|
||||
if (!text) return data;
|
||||
if (data.length === 0 || data === ' ') return data;
|
||||
|
||||
return emojifyText(data, customEmojis);
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
export default emojify;
|
||||
|
||||
export const buildCustomEmojis = (customEmojis: any) => {
|
||||
const emojis: EmojiMart<EmojiMartCustom>[] = [];
|
||||
|
||||
customEmojis.forEach((emoji: any) => {
|
||||
const shortcode = emoji.get('shortcode');
|
||||
const url = emoji.get('static_url');
|
||||
const name = shortcode.replace(':', '');
|
||||
|
||||
emojis.push({
|
||||
id: name,
|
||||
name,
|
||||
keywords: [name],
|
||||
skins: [{ src: url }],
|
||||
});
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
|
@ -1,111 +0,0 @@
|
|||
import data, { EmojiData } from './data';
|
||||
|
||||
const stripLeadingZeros = /^0+/;
|
||||
|
||||
function replaceAll(str: string, find: string, replace: string) {
|
||||
return str.replace(new RegExp(find, 'g'), replace);
|
||||
}
|
||||
|
||||
interface UnicodeMap {
|
||||
[s: string]: {
|
||||
unified: string
|
||||
shortcode: string
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Twemoji strips their hex codes from unicode codepoints to make it look "pretty"
|
||||
* - leading 0s are removed
|
||||
* - fe0f is removed unless it has 200d
|
||||
* - fe0f is NOT removed for 1f441-fe0f-200d-1f5e8-fe0f even though it has a 200d
|
||||
*
|
||||
* this is all wrong
|
||||
*/
|
||||
|
||||
const blacklist = {
|
||||
'1f441-fe0f-200d-1f5e8-fe0f': true,
|
||||
};
|
||||
|
||||
const tweaks = {
|
||||
'#⃣': ['23-20e3', 'hash'],
|
||||
'*⃣': ['2a-20e3', 'keycap_star'],
|
||||
'0⃣': ['30-20e3', 'zero'],
|
||||
'1⃣': ['31-20e3', 'one'],
|
||||
'2⃣': ['32-20e3', 'two'],
|
||||
'3⃣': ['33-20e3', 'three'],
|
||||
'4⃣': ['34-20e3', 'four'],
|
||||
'5⃣': ['35-20e3', 'five'],
|
||||
'6⃣': ['36-20e3', 'six'],
|
||||
'7⃣': ['37-20e3', 'seven'],
|
||||
'8⃣': ['38-20e3', 'eight'],
|
||||
'9⃣': ['39-20e3', 'nine'],
|
||||
'❤🔥': ['2764-fe0f-200d-1f525', 'heart_on_fire'],
|
||||
'❤🩹': ['2764-fe0f-200d-1fa79', 'mending_heart'],
|
||||
'👁🗨️': ['1f441-fe0f-200d-1f5e8-fe0f', 'eye-in-speech-bubble'],
|
||||
'👁️🗨': ['1f441-fe0f-200d-1f5e8-fe0f', 'eye-in-speech-bubble'],
|
||||
'👁🗨': ['1f441-fe0f-200d-1f5e8-fe0f', 'eye-in-speech-bubble'],
|
||||
'🕵♂️': ['1f575-fe0f-200d-2642-fe0f', 'male-detective'],
|
||||
'🕵️♂': ['1f575-fe0f-200d-2642-fe0f', 'male-detective'],
|
||||
'🕵♂': ['1f575-fe0f-200d-2642-fe0f', 'male-detective'],
|
||||
'🕵♀️': ['1f575-fe0f-200d-2640-fe0f', 'female-detective'],
|
||||
'🕵️♀': ['1f575-fe0f-200d-2640-fe0f', 'female-detective'],
|
||||
'🕵♀': ['1f575-fe0f-200d-2640-fe0f', 'female-detective'],
|
||||
'🏌♂️': ['1f3cc-fe0f-200d-2642-fe0f', 'man-golfing'],
|
||||
'🏌️♂': ['1f3cc-fe0f-200d-2642-fe0f', 'man-golfing'],
|
||||
'🏌♂': ['1f3cc-fe0f-200d-2642-fe0f', 'man-golfing'],
|
||||
'🏌♀️': ['1f3cc-fe0f-200d-2640-fe0f', 'woman-golfing'],
|
||||
'🏌️♀': ['1f3cc-fe0f-200d-2640-fe0f', 'woman-golfing'],
|
||||
'🏌♀': ['1f3cc-fe0f-200d-2640-fe0f', 'woman-golfing'],
|
||||
'⛹♂️': ['26f9-fe0f-200d-2642-fe0f', 'man-bouncing-ball'],
|
||||
'⛹️♂': ['26f9-fe0f-200d-2642-fe0f', 'man-bouncing-ball'],
|
||||
'⛹♂': ['26f9-fe0f-200d-2642-fe0f', 'man-bouncing-ball'],
|
||||
'⛹♀️': ['26f9-fe0f-200d-2640-fe0f', 'woman-bouncing-ball'],
|
||||
'⛹️♀': ['26f9-fe0f-200d-2640-fe0f', 'woman-bouncing-ball'],
|
||||
'⛹♀': ['26f9-fe0f-200d-2640-fe0f', 'woman-bouncing-ball'],
|
||||
'🏋♂️': ['1f3cb-fe0f-200d-2642-fe0f', 'man-lifting-weights'],
|
||||
'🏋️♂': ['1f3cb-fe0f-200d-2642-fe0f', 'man-lifting-weights'],
|
||||
'🏋♂': ['1f3cb-fe0f-200d-2642-fe0f', 'man-lifting-weights'],
|
||||
'🏋♀️': ['1f3cb-fe0f-200d-2640-fe0f', 'woman-lifting-weights'],
|
||||
'🏋️♀': ['1f3cb-fe0f-200d-2640-fe0f', 'woman-lifting-weights'],
|
||||
'🏋♀': ['1f3cb-fe0f-200d-2640-fe0f', 'woman-lifting-weights'],
|
||||
'🏳🌈': ['1f3f3-fe0f-200d-1f308', 'rainbow_flag'],
|
||||
'🏳⚧️': ['1f3f3-fe0f-200d-26a7-fe0f', 'transgender_flag'],
|
||||
'🏳️⚧': ['1f3f3-fe0f-200d-26a7-fe0f', 'transgender_flag'],
|
||||
'🏳⚧': ['1f3f3-fe0f-200d-26a7-fe0f', 'transgender_flag'],
|
||||
};
|
||||
|
||||
const stripcodes = (unified: string, native: string) => {
|
||||
const stripped = unified.replace(stripLeadingZeros, '');
|
||||
|
||||
if (unified.includes('200d') && !(unified in blacklist)) {
|
||||
return stripped;
|
||||
} else {
|
||||
return replaceAll(stripped, '-fe0f', '');
|
||||
}
|
||||
};
|
||||
|
||||
export const generateMappings = (data: EmojiData): UnicodeMap => {
|
||||
const result: UnicodeMap = {};
|
||||
const emojis = Object.values(data.emojis ?? {});
|
||||
|
||||
for (const value of emojis) {
|
||||
for (const item of value.skins) {
|
||||
const { unified, native } = item;
|
||||
const stripped = stripcodes(unified, native);
|
||||
|
||||
result[native] = { unified: stripped, shortcode: value.id };
|
||||
}
|
||||
}
|
||||
|
||||
for (const [native, [unified, shortcode]] of Object.entries(tweaks)) {
|
||||
const stripped = stripcodes(unified, native);
|
||||
|
||||
result[native] = { unified: stripped, shortcode };
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const unicodeMapping = generateMappings(data);
|
||||
|
||||
export default unicodeMapping;
|
|
@ -1,74 +0,0 @@
|
|||
import { Index } from 'flexsearch-ts';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
import data from './data';
|
||||
|
||||
import type { Emoji } from './index';
|
||||
|
||||
const index = new Index({
|
||||
tokenize: 'full',
|
||||
optimize: true,
|
||||
context: true,
|
||||
});
|
||||
|
||||
for (const [key, emoji] of Object.entries(data.emojis)) {
|
||||
index.add('n' + key, emoji.name);
|
||||
}
|
||||
|
||||
export interface searchOptions {
|
||||
maxResults?: number
|
||||
custom?: any
|
||||
}
|
||||
|
||||
export const addCustomToPool = (customEmojis: any[]) => {
|
||||
// @ts-ignore
|
||||
for (const key in index.register) {
|
||||
if (key[0] === 'c') {
|
||||
index.remove(key); // remove old custom emojis
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
|
||||
for (const emoji of customEmojis) {
|
||||
index.add('c' + i++, emoji.id);
|
||||
}
|
||||
};
|
||||
|
||||
// we can share an index by prefixing custom emojis with 'c' and native with 'n'
|
||||
const search = (
|
||||
str: string, { maxResults = 5 }: searchOptions = {},
|
||||
custom_emojis?: ImmutableList<ImmutableMap<string, string>>,
|
||||
): Emoji[] => {
|
||||
return index.search(str, maxResults)
|
||||
.flatMap((id) => {
|
||||
if (typeof id !== 'string') return;
|
||||
|
||||
if (id[0] === 'c' && custom_emojis) {
|
||||
const index = Number(id.slice(1));
|
||||
const custom = custom_emojis.get(index);
|
||||
|
||||
if (custom) {
|
||||
return {
|
||||
id: custom.get('shortcode', ''),
|
||||
colons: ':' + custom.get('shortcode', '') + ':',
|
||||
custom: true,
|
||||
imageUrl: custom.get('static_url', ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const skins = data.emojis[id.slice(1)]?.skins;
|
||||
|
||||
if (skins) {
|
||||
return {
|
||||
id: id.slice(1),
|
||||
colons: ':' + id.slice(1) + ':',
|
||||
unified: skins[0].unified,
|
||||
native: skins[0].native,
|
||||
};
|
||||
}
|
||||
}).filter(Boolean) as Emoji[];
|
||||
};
|
||||
|
||||
export default search;
|
|
@ -1,279 +0,0 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { createFilter, fetchFilter, updateFilter } from 'soapbox/actions/filters';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import MissingIndicator from 'soapbox/components/missing-indicator';
|
||||
import { Button, Column, Form, FormActions, FormGroup, HStack, Input, Stack, Streamfield, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeFilter } from 'soapbox/normalizers';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import { SelectDropdown } from '../forms';
|
||||
|
||||
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
|
||||
|
||||
interface IFilterField {
|
||||
id?: string
|
||||
keyword: string
|
||||
whole_word: boolean
|
||||
_destroy?: boolean
|
||||
}
|
||||
|
||||
interface IEditFilter {
|
||||
params: { id?: string }
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' },
|
||||
title: { id: 'column.filters.title', defaultMessage: 'Title' },
|
||||
keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' },
|
||||
keywords: { id: 'column.filters.keywords', defaultMessage: 'Keywords or phrases' },
|
||||
expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' },
|
||||
home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
|
||||
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
|
||||
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
|
||||
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
|
||||
accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' },
|
||||
drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' },
|
||||
drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' },
|
||||
hide_header: { id: 'column.filters.hide_header', defaultMessage: 'Hide completely' },
|
||||
hide_hint: { id: 'column.filters.hide_hint', defaultMessage: 'Completely hide the filtered content, instead of showing a warning' },
|
||||
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' },
|
||||
edit: { id: 'column.filters.edit', defaultMessage: 'Edit Filter' },
|
||||
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
|
||||
expiration_never: { id: 'colum.filters.expiration.never', defaultMessage: 'Never' },
|
||||
expiration_1800: { id: 'colum.filters.expiration.1800', defaultMessage: '30 minutes' },
|
||||
expiration_3600: { id: 'colum.filters.expiration.3600', defaultMessage: '1 hour' },
|
||||
expiration_21600: { id: 'colum.filters.expiration.21600', defaultMessage: '6 hours' },
|
||||
expiration_43200: { id: 'colum.filters.expiration.43200', defaultMessage: '12 hours' },
|
||||
expiration_86400: { id: 'colum.filters.expiration.86400', defaultMessage: '1 day' },
|
||||
expiration_604800: { id: 'colum.filters.expiration.604800', defaultMessage: '1 week' },
|
||||
});
|
||||
|
||||
const FilterField: StreamfieldComponent<IFilterField> = ({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChange = (key: string): React.ChangeEventHandler<HTMLInputElement> =>
|
||||
e => onChange({ ...value, [key]: e.currentTarget[e.currentTarget.type === 'checkbox' ? 'checked' : 'value'] });
|
||||
|
||||
return (
|
||||
<HStack space={2} grow>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='w-2/5 grow'
|
||||
value={value.keyword}
|
||||
onChange={handleChange('keyword')}
|
||||
placeholder={intl.formatMessage(messages.keyword)}
|
||||
/>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Toggle
|
||||
checked={value.whole_word}
|
||||
onChange={handleChange('whole_word')}
|
||||
/>
|
||||
|
||||
<Text tag='span' theme='muted'>
|
||||
<FormattedMessage id='column.filters.whole_word' defaultMessage='Whole word' />
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const EditFilter: React.FC<IEditFilter> = ({ params }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [expiresIn, setExpiresIn] = useState<string | null>(null);
|
||||
const [homeTimeline, setHomeTimeline] = useState(true);
|
||||
const [publicTimeline, setPublicTimeline] = useState(false);
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
const [conversations, setConversations] = useState(false);
|
||||
const [accounts, setAccounts] = useState(false);
|
||||
const [hide, setHide] = useState(false);
|
||||
const [keywords, setKeywords] = useState<IFilterField[]>([{ keyword: '', whole_word: false }]);
|
||||
|
||||
const expirations = useMemo(() => ({
|
||||
'': intl.formatMessage(messages.expiration_never),
|
||||
1800: intl.formatMessage(messages.expiration_1800),
|
||||
3600: intl.formatMessage(messages.expiration_3600),
|
||||
21600: intl.formatMessage(messages.expiration_21600),
|
||||
43200: intl.formatMessage(messages.expiration_43200),
|
||||
86400: intl.formatMessage(messages.expiration_86400),
|
||||
604800: intl.formatMessage(messages.expiration_604800),
|
||||
}), []);
|
||||
|
||||
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
|
||||
setExpiresIn(e.target.value);
|
||||
};
|
||||
|
||||
const handleAddNew: React.FormEventHandler = e => {
|
||||
e.preventDefault();
|
||||
const context: Array<string> = [];
|
||||
|
||||
if (homeTimeline) {
|
||||
context.push('home');
|
||||
}
|
||||
if (publicTimeline) {
|
||||
context.push('public');
|
||||
}
|
||||
if (notifications) {
|
||||
context.push('notifications');
|
||||
}
|
||||
if (conversations) {
|
||||
context.push('thread');
|
||||
}
|
||||
if (accounts) {
|
||||
context.push('account');
|
||||
}
|
||||
|
||||
dispatch(params.id
|
||||
? updateFilter(params.id, title, expiresIn, context, hide, keywords)
|
||||
: createFilter(title, expiresIn, context, hide, keywords)).then(() => {
|
||||
history.push('/filters');
|
||||
}).catch(() => {
|
||||
toast.error(intl.formatMessage(messages.create_error));
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeKeyword = (keywords: { keyword: string, whole_word: boolean }[]) => setKeywords(keywords);
|
||||
|
||||
const handleAddKeyword = () => setKeywords(keywords => [...keywords, { keyword: '', whole_word: false }]);
|
||||
|
||||
const handleRemoveKeyword = (i: number) => setKeywords(keywords => keywords[i].id
|
||||
? keywords.map((keyword, index) => index === i ? { ...keyword, _destroy: true } : keyword)
|
||||
: keywords.filter((_, index) => index !== i));
|
||||
|
||||
useEffect(() => {
|
||||
if (params.id) {
|
||||
setLoading(true);
|
||||
dispatch(fetchFilter(params.id))?.then((res: any) => {
|
||||
if (res.filter) {
|
||||
const filter = normalizeFilter(res.filter);
|
||||
|
||||
setTitle(filter.title);
|
||||
setHomeTimeline(filter.context.includes('home'));
|
||||
setPublicTimeline(filter.context.includes('public'));
|
||||
setNotifications(filter.context.includes('notifications'));
|
||||
setConversations(filter.context.includes('thread'));
|
||||
setAccounts(filter.context.includes('account'));
|
||||
setHide(filter.filter_action === 'hide');
|
||||
setKeywords(filter.keywords.toJS());
|
||||
} else {
|
||||
setNotFound(true);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [params.id]);
|
||||
|
||||
if (notFound) return <MissingIndicator />;
|
||||
|
||||
return (
|
||||
<Column className='filter-settings-panel' label={intl.formatMessage(messages.subheading_add_new)}>
|
||||
<Form onSubmit={handleAddNew}>
|
||||
<FormGroup labelText={intl.formatMessage(messages.title)}>
|
||||
<Input
|
||||
required
|
||||
type='text'
|
||||
name='title'
|
||||
value={title}
|
||||
onChange={({ target }) => setTitle(target.value)}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{features.filtersExpiration && (
|
||||
<FormGroup labelText={intl.formatMessage(messages.expires)}>
|
||||
<SelectDropdown
|
||||
items={expirations}
|
||||
defaultValue={''}
|
||||
onChange={handleSelectChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
<Stack>
|
||||
<Text size='sm' weight='medium'>
|
||||
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
|
||||
</Text>
|
||||
<Text size='xs' theme='muted'>
|
||||
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<List>
|
||||
<ListItem label={intl.formatMessage(messages.home_timeline)}>
|
||||
<Toggle
|
||||
checked={homeTimeline}
|
||||
onChange={({ target }) => setHomeTimeline(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem label={intl.formatMessage(messages.public_timeline)}>
|
||||
<Toggle
|
||||
checked={publicTimeline}
|
||||
onChange={({ target }) => setPublicTimeline(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem label={intl.formatMessage(messages.notifications)}>
|
||||
<Toggle
|
||||
checked={notifications}
|
||||
onChange={({ target }) => setNotifications(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem label={intl.formatMessage(messages.conversations)}>
|
||||
<Toggle
|
||||
checked={conversations}
|
||||
onChange={({ target }) => setConversations(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
{features.filtersV2 && (
|
||||
<ListItem label={intl.formatMessage(messages.accounts)}>
|
||||
<Toggle
|
||||
checked={accounts}
|
||||
onChange={({ target }) => setAccounts(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
label={intl.formatMessage(features.filtersV2 ? messages.hide_header : messages.drop_header)}
|
||||
hint={intl.formatMessage(features.filtersV2 ? messages.hide_hint : messages.drop_hint)}
|
||||
>
|
||||
<Toggle
|
||||
checked={hide}
|
||||
onChange={({ target }) => setHide(target.checked)}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Streamfield
|
||||
label={intl.formatMessage(messages.keywords)}
|
||||
component={FilterField}
|
||||
values={keywords}
|
||||
onChange={handleChangeKeyword}
|
||||
onAddItem={handleAddKeyword}
|
||||
onRemoveItem={handleRemoveKeyword}
|
||||
minItems={1}
|
||||
maxItems={features.filtersV2 ? Infinity : 1}
|
||||
/>
|
||||
|
||||
<FormActions>
|
||||
<Button type='submit' theme='primary' disabled={loading}>
|
||||
{intl.formatMessage(params.id ? messages.edit : messages.add_new)}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditFilter;
|
|
@ -1,52 +0,0 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags';
|
||||
import Hashtag from 'soapbox/components/hashtag';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandFollowedHashtags());
|
||||
}, 300, { leading: true });
|
||||
|
||||
const FollowedTags = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchFollowedHashtags());
|
||||
}, []);
|
||||
|
||||
const tags = useAppSelector((state => state.followed_tags.items));
|
||||
const isLoading = useAppSelector((state => state.followed_tags.isLoading));
|
||||
const hasMore = useAppSelector((state => !!state.followed_tags.next));
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage="You haven't followed any hashtag yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='followed_tags'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
placeholderComponent={PlaceholderHashtag}
|
||||
placeholderCount={5}
|
||||
itemClassName='pb-3'
|
||||
>
|
||||
{tags.map(tag => <Hashtag key={tag.name} hashtag={tag} />)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowedTags;
|
|
@ -1,131 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { buildGroup, buildGroupRelationship } from 'soapbox/jest/factory';
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupActionButton from '../group-action-button';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupActionButton />', () => {
|
||||
describe('with no group relationship', () => {
|
||||
beforeEach(() => {
|
||||
group = buildGroup({
|
||||
relationship: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = { ...group, locked: true };
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = { ...group, locked: false };
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no group relationship member', () => {
|
||||
beforeEach(() => {
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
member: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = { ...group, locked: true };
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = { ...group, locked: false };
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has requested to join', () => {
|
||||
beforeEach(() => {
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
requested: true,
|
||||
member: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Cancel Request button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Cancel Request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is an Admin', () => {
|
||||
beforeEach(() => {
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: GroupRoles.OWNER,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Manage Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Manage Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is just a member', () => {
|
||||
beforeEach(() => {
|
||||
group = buildGroup({
|
||||
relationship: buildGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: GroupRoles.USER,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Leave Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Leave Group');
|
||||
});
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue