mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
Compare commits
10
Commits
a42145c678
...
5b7b4360d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b7b4360d2 | ||
|
|
081460c7f6 | ||
|
|
17f385c1c3 | ||
|
|
8f0d47b850 | ||
|
|
4eb785b6ad | ||
|
|
b5288d7e03 | ||
|
|
81176654ba | ||
|
|
f51c576952 | ||
|
|
110b659213 | ||
|
|
cc132ad6b4 |
@@ -0,0 +1,40 @@
|
||||
# Contributing to plspace
|
||||
|
||||
Contributions are welcome when they follow all of the rules below. By submitting
|
||||
a contribution, you agree to these requirements.
|
||||
|
||||
## Be nice
|
||||
|
||||
Be nice to everyone, no matter who they are. Harassment, hostility, personal
|
||||
attacks and discriminatory behavior are not acceptable in project discussions,
|
||||
reviews or any other project space.
|
||||
|
||||
## Project governance
|
||||
|
||||
The original author retains sole discretion over the project. They may accept or
|
||||
reject contributions and include or exclude anyone from project participation
|
||||
or maintainer-managed project spaces using any criteria they choose.
|
||||
|
||||
This governance rule does not limit anyone's rights to use, modify or distribute
|
||||
the released software under its license.
|
||||
|
||||
## LLM assistance is required
|
||||
|
||||
You are required to use a large language model (LLM) to at least partially write
|
||||
and test every contribution. The LLM must assist with both implementation and
|
||||
testing; merely asking it a general question does not satisfy this requirement.
|
||||
|
||||
In your contribution description, briefly identify the LLM or LLM-powered tool
|
||||
you used and explain how it assisted with the implementation and tests. You
|
||||
remain responsible for reviewing the generated work and for the correctness,
|
||||
security and licensing of everything you submit.
|
||||
|
||||
## License
|
||||
|
||||
All contributions must be licensed under the same license as the original
|
||||
project: the GNU Affero General Public License, version 3 (AGPLv3), as provided
|
||||
in [`LICENSE.txt`](LICENSE.txt).
|
||||
|
||||
Only submit work that you have the right to contribute under that license.
|
||||
Third-party code, assets and dependencies must also be compatible with the
|
||||
project's license.
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -5,10 +5,20 @@
|
||||
A static, client-side frontend for Pleroma that looks and behaves like MySpace
|
||||
circa 2005.
|
||||
|
||||
This is a 100% Claude and ChatGPT vibe-coded application.
|
||||
|
||||
Point it at your server. It talks to that server's REST API directly from the
|
||||
browser — there is no plspace backend, and `dist/` is a folder of static files
|
||||
you can host anywhere.
|
||||
|
||||
## Repository
|
||||
|
||||
The canonical repository is hosted on Radicle:
|
||||
`rad://z2gAKC6ESt5ZBV419uVPf2vFtEHCT`.
|
||||
|
||||
The backup mirror is
|
||||
[`https://git.shipoclu.com/moon/plspace.git`](https://git.shipoclu.com/moon/plspace.git).
|
||||
|
||||
## What it looks like
|
||||
|
||||
| MySpace | plspace |
|
||||
@@ -40,8 +50,9 @@ GitHub Pages — with no server rewrite rules.
|
||||
|
||||
plspace registers itself as an OAuth app on your server the first time you sign
|
||||
in there, then redirects you to that server's own consent screen. Your password
|
||||
is never entered into plspace; it only ever receives an access token, which is
|
||||
stored in `localStorage` and used directly from your browser.
|
||||
is never entered into plspace; it only ever receives OAuth tokens, which are
|
||||
stored in `localStorage` and used directly from your browser. Short-lived access
|
||||
tokens are renewed with a server-issued refresh token when available.
|
||||
|
||||
PKCE is used where the server supports it, with an automatic fallback for
|
||||
servers that don't.
|
||||
@@ -103,9 +114,14 @@ rewritten to in-app routes; every other link gets `target="_blank"` with
|
||||
|
||||
## Compatibility
|
||||
|
||||
Written against the standard `/api/v1` REST API and tested against live servers.
|
||||
Written against the standard `/api/v1` REST API and the local Egregoros
|
||||
implementation, as well as live servers.
|
||||
Anything implementations differ on degrades rather than fails: `/api/v2/instance`
|
||||
falls back to v1, `/api/v1/accounts/lookup` falls back to search, pagination
|
||||
falls back to the last item's id when a server drops the `Link` header, and
|
||||
per-account privacy flags are read in both their spellings. Other software
|
||||
speaking the same API therefore works, but Pleroma is what this targets.
|
||||
per-account privacy flags are read in both their spellings. Egregoros'
|
||||
hour-long access tokens and rotating refresh tokens are supported; notification
|
||||
folders are filtered defensively because its current API ignores `types[]`.
|
||||
Profile controls are capability-aware, so unsupported Egregoros fields are not
|
||||
shown as though they could be saved. Other software speaking the same API
|
||||
therefore works, but Pleroma is what this targets.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SiteHeader from '$components/chrome/SiteHeader.svelte'
|
||||
import SiteNav from '$components/chrome/SiteNav.svelte'
|
||||
import SiteFooter from '$components/chrome/SiteFooter.svelte'
|
||||
import NotificationToasts from '$components/chrome/NotificationToasts.svelte'
|
||||
import RefreshHotkey from '$components/common/RefreshHotkey.svelte'
|
||||
import Home from '$routes/Home.svelte'
|
||||
import Profile from '$routes/Profile.svelte'
|
||||
@@ -60,6 +61,7 @@
|
||||
</script>
|
||||
|
||||
<RefreshHotkey onrefresh={() => timelineRefresh.refresh()} />
|
||||
<NotificationToasts />
|
||||
|
||||
<div class="site">
|
||||
<SiteHeader />
|
||||
|
||||
@@ -24,6 +24,17 @@
|
||||
function toggle(id: string): void {
|
||||
revealed = { ...revealed, [id]: !revealed[id] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the browser to decode the first real frame while it loads metadata.
|
||||
* The tiny media-fragment seek also makes the video's intrinsic dimensions
|
||||
* available before playback on browsers that otherwise leave it at 300×150.
|
||||
*/
|
||||
function videoPreviewUrl(url: string): string {
|
||||
const fragment = url.indexOf('#')
|
||||
const base = fragment >= 0 ? url.slice(0, fragment) : url
|
||||
return `${base}#t=0.001`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if attachments.length > 0}
|
||||
@@ -39,12 +50,12 @@
|
||||
{#if media.type === 'video' || media.type === 'gifv'}
|
||||
<video
|
||||
class="attachment-media"
|
||||
src={media.url}
|
||||
src={videoPreviewUrl(media.url)}
|
||||
poster={media.preview_url ?? undefined}
|
||||
controls
|
||||
playsinline
|
||||
loop={media.type === 'gifv'}
|
||||
preload="none"
|
||||
preload="metadata"
|
||||
>
|
||||
<!-- Remote media carries no caption track; declared so the
|
||||
requirement is explicit rather than merely unmet. -->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MediaAttachment } from '$lib/api/types'
|
||||
import Attachments from './Attachments.svelte'
|
||||
|
||||
function video(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
|
||||
return {
|
||||
id: 'video-1',
|
||||
type: 'video',
|
||||
url: 'https://media.example.test/movie.mp4?download=1',
|
||||
preview_url: 'https://media.example.test/movie-preview.jpg',
|
||||
description: 'A raw video',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Attachments raw-video previews', () => {
|
||||
it('loads metadata and seeks just past zero to expose dimensions and a first frame', () => {
|
||||
const view = render(Attachments, { attachments: [video()] })
|
||||
const element = view.container.querySelector('video')
|
||||
|
||||
expect(element).not.toBeNull()
|
||||
expect(element).toHaveAttribute(
|
||||
'src',
|
||||
'https://media.example.test/movie.mp4?download=1#t=0.001',
|
||||
)
|
||||
expect(element).toHaveAttribute('preload', 'metadata')
|
||||
expect(element).toHaveAttribute('poster', 'https://media.example.test/movie-preview.jpg')
|
||||
expect(element).toHaveAttribute('controls')
|
||||
expect(element).toHaveAttribute('playsinline')
|
||||
})
|
||||
|
||||
it('replaces an existing fragment instead of producing an invalid double fragment', () => {
|
||||
const view = render(Attachments, {
|
||||
attachments: [video({ type: 'gifv', url: 'https://media.example.test/loop.mp4#old' })],
|
||||
})
|
||||
const element = view.container.querySelector('video')
|
||||
|
||||
expect(element).toHaveAttribute('src', 'https://media.example.test/loop.mp4#t=0.001')
|
||||
expect(element).toHaveAttribute('loop')
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@
|
||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
import RichText from '../common/RichText.svelte'
|
||||
import MfmContent from '../common/MfmContent.svelte'
|
||||
import Attachments from './Attachments.svelte'
|
||||
import PollView from './PollView.svelte'
|
||||
import PreviewCardView from './PreviewCardView.svelte'
|
||||
@@ -181,7 +181,7 @@
|
||||
{#if entry.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
|
||||
<RichText
|
||||
<MfmContent
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
@@ -194,7 +194,7 @@
|
||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
<MfmContent
|
||||
html={entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
@@ -208,7 +208,11 @@
|
||||
{/if}
|
||||
|
||||
{#if entry.poll}
|
||||
<PollView poll={entry.poll} />
|
||||
<PollView
|
||||
poll={entry.poll}
|
||||
authorId={entry.account.id}
|
||||
onupdate={(poll) => onupdate?.(applyLocal(status, { poll }))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if entry.card && entry.media_attachments.length === 0 && youtubeVideoIds.length === 0}
|
||||
|
||||
@@ -48,3 +48,21 @@ describe('BlogEntry YouTube embeds', () => {
|
||||
expect(view.getByTitle('YouTube video')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlogEntry MFM rendering', () => {
|
||||
it('renders the Pleroma API MFM HTML inside a backend-free blog entry', () => {
|
||||
const view = renderEntry({
|
||||
content:
|
||||
'<p>It <span class="mfm-spin" data-mfm-speed="2s" data-mfm-left>works</span></p>',
|
||||
})
|
||||
|
||||
const effect = view.getByText('works')
|
||||
expect(effect).toHaveClass('mfm', '-pause')
|
||||
expect(effect).toHaveAttribute('data-mfm-operator', 'spin')
|
||||
expect(effect).toHaveStyle({
|
||||
animationName: 'mfm-spin',
|
||||
animationDuration: '2s',
|
||||
animationDirection: 'reverse',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,26 +39,113 @@
|
||||
untrack(() => inReplyTo?.visibility ?? initialVisibility),
|
||||
)
|
||||
let attachments = $state<MediaAttachment[]>([])
|
||||
let showPoll = $state(false)
|
||||
let pollOptions = $state(['', ''])
|
||||
let pollMultiple = $state(false)
|
||||
let pollExpiresIn = $state(86_400)
|
||||
let busy = $state(false)
|
||||
let uploading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const maxCharacters = $derived(session.instance?.configuration?.statuses?.max_characters ?? 500)
|
||||
const maxAttachments = $derived(session.instance?.configuration?.statuses?.max_media_attachments ?? 4)
|
||||
const maxPollOptions = $derived(session.instance?.configuration?.polls?.max_options ?? 4)
|
||||
const maxPollOptionCharacters = $derived(
|
||||
session.instance?.configuration?.polls?.max_characters_per_option ?? 50,
|
||||
)
|
||||
const minPollExpiration = $derived(session.instance?.configuration?.polls?.min_expiration ?? 300)
|
||||
const maxPollExpiration = $derived(
|
||||
session.instance?.configuration?.polls?.max_expiration ?? 2_592_000,
|
||||
)
|
||||
const pollDurations = $derived(durationChoices(minPollExpiration, maxPollExpiration))
|
||||
const completedPollOptions = $derived(
|
||||
pollOptions.map((option) => option.trim()).filter(Boolean),
|
||||
)
|
||||
const pollValid = $derived(
|
||||
!showPoll ||
|
||||
(completedPollOptions.length >= 2 &&
|
||||
completedPollOptions.every((option) => option.length <= maxPollOptionCharacters)),
|
||||
)
|
||||
const remaining = $derived(maxCharacters - text.length - warning.length)
|
||||
const canPost = $derived(
|
||||
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
|
||||
!busy &&
|
||||
!uploading &&
|
||||
remaining >= 0 &&
|
||||
pollValid &&
|
||||
!(showPoll && attachments.length > 0) &&
|
||||
(text.trim().length > 0 || attachments.length > 0),
|
||||
)
|
||||
|
||||
async function onFiles(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
if (files.length === 0) return
|
||||
const POLL_DURATION_PRESETS = [
|
||||
300,
|
||||
1_800,
|
||||
3_600,
|
||||
6 * 3_600,
|
||||
86_400,
|
||||
3 * 86_400,
|
||||
7 * 86_400,
|
||||
30 * 86_400,
|
||||
]
|
||||
|
||||
function durationChoices(minimum: number, maximum: number): number[] {
|
||||
const min = Math.max(0, minimum)
|
||||
const max = Math.max(min, maximum)
|
||||
const preferred = Math.min(max, Math.max(min, 86_400))
|
||||
return [...new Set([...POLL_DURATION_PRESETS.filter((value) => value >= min && value <= max), preferred])]
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function durationLabel(seconds: number): string {
|
||||
if (seconds % 86_400 === 0) {
|
||||
const days = seconds / 86_400
|
||||
return `${days} day${days === 1 ? '' : 's'}`
|
||||
}
|
||||
if (seconds % 3_600 === 0) {
|
||||
const hours = seconds / 3_600
|
||||
return `${hours} hour${hours === 1 ? '' : 's'}`
|
||||
}
|
||||
const minutes = Math.max(1, Math.round(seconds / 60))
|
||||
return `${minutes} minute${minutes === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
function togglePoll(): void {
|
||||
if (showPoll) {
|
||||
showPoll = false
|
||||
return
|
||||
}
|
||||
if (attachments.length > 0) return
|
||||
showPoll = true
|
||||
pollOptions = ['', '']
|
||||
pollMultiple = false
|
||||
pollExpiresIn = pollDurations.includes(86_400) ? 86_400 : (pollDurations[0] ?? 86_400)
|
||||
}
|
||||
|
||||
function addPollOption(): void {
|
||||
if (pollOptions.length < maxPollOptions) pollOptions = [...pollOptions, '']
|
||||
}
|
||||
|
||||
function removePollOption(index: number): void {
|
||||
if (pollOptions.length <= 2) return
|
||||
pollOptions = pollOptions.filter((_, optionIndex) => optionIndex !== index)
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]): Promise<void> {
|
||||
if (files.length === 0 || uploading) return
|
||||
if (showPoll) {
|
||||
error = 'Remove the poll before adding media.'
|
||||
return
|
||||
}
|
||||
|
||||
const availableSlots = maxAttachments - attachments.length
|
||||
if (availableSlots <= 0) {
|
||||
error = `This server allows up to ${maxAttachments} attachments.`
|
||||
return
|
||||
}
|
||||
|
||||
uploading = true
|
||||
error = null
|
||||
try {
|
||||
for (const file of files.slice(0, maxAttachments - attachments.length)) {
|
||||
for (const file of files.slice(0, availableSlots)) {
|
||||
const media = await endpoints.uploadMedia(session.api, file)
|
||||
attachments = [...attachments, media]
|
||||
}
|
||||
@@ -66,10 +153,30 @@
|
||||
error = cause instanceof Error ? cause.message : 'Upload failed.'
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onFiles(event: Event): Promise<void> {
|
||||
const input = event.currentTarget as HTMLInputElement
|
||||
try {
|
||||
await uploadFiles(Array.from(input.files ?? []))
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clipboardImages(event: ClipboardEvent): File[] {
|
||||
const itemImages = Array.from(event.clipboardData?.items ?? [])
|
||||
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
|
||||
if (itemImages.length > 0) return itemImages
|
||||
return Array.from(event.clipboardData?.files ?? []).filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
)
|
||||
}
|
||||
|
||||
function removeAttachment(id: string): void {
|
||||
attachments = attachments.filter((media) => media.id !== id)
|
||||
}
|
||||
@@ -87,11 +194,21 @@
|
||||
visibility,
|
||||
spoiler_text: showWarning ? warning : undefined,
|
||||
media_ids: attachments.map((media) => media.id),
|
||||
poll: showPoll
|
||||
? {
|
||||
options: completedPollOptions,
|
||||
expires_in: pollExpiresIn,
|
||||
multiple: pollMultiple,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
text = ''
|
||||
warning = ''
|
||||
showWarning = false
|
||||
attachments = []
|
||||
showPoll = false
|
||||
pollOptions = ['', '']
|
||||
pollMultiple = false
|
||||
onposted?.(created)
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not post that.'
|
||||
@@ -99,10 +216,40 @@
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function composerInteractions(form: HTMLFormElement): { destroy(): void } {
|
||||
function keydown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
if (canPost) form.requestSubmit()
|
||||
}
|
||||
|
||||
function paste(event: ClipboardEvent): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLTextAreaElement) || !target.classList.contains('composer-body')) {
|
||||
return
|
||||
}
|
||||
|
||||
const images = clipboardImages(event)
|
||||
if (images.length === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
void uploadFiles(images)
|
||||
}
|
||||
|
||||
form.addEventListener('keydown', keydown)
|
||||
form.addEventListener('paste', paste)
|
||||
return {
|
||||
destroy: () => {
|
||||
form.removeEventListener('keydown', keydown)
|
||||
form.removeEventListener('paste', paste)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if session.signedIn}
|
||||
<form class="composer" onsubmit={submit}>
|
||||
<form class="composer" onsubmit={submit} use:composerInteractions>
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
@@ -140,6 +287,63 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if showPoll}
|
||||
<fieldset class="composer-poll">
|
||||
<legend>Poll options</legend>
|
||||
<div class="composer-poll-options">
|
||||
{#each pollOptions as _, index (index)}
|
||||
<div class="composer-poll-option">
|
||||
<label class="visually-hidden" for={`composer-poll-option-${index}`}>
|
||||
Poll option {index + 1}
|
||||
</label>
|
||||
<input
|
||||
id={`composer-poll-option-${index}`}
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={pollOptions[index]}
|
||||
maxlength={maxPollOptionCharacters}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
required={index < 2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
disabled={pollOptions.length <= 2}
|
||||
aria-label={`Remove poll option ${index + 1}`}
|
||||
onclick={() => removePollOption(index)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="composer-poll-settings">
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
disabled={pollOptions.length >= maxPollOptions}
|
||||
onclick={addPollOption}
|
||||
>
|
||||
Add option
|
||||
</button>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={pollMultiple} />
|
||||
Allow multiple choices
|
||||
</label>
|
||||
<label for="composer-poll-duration">Keep open for</label>
|
||||
<select id="composer-poll-duration" bind:value={pollExpiresIn}>
|
||||
{#each pollDurations as seconds (seconds)}
|
||||
<option value={seconds}>{durationLabel(seconds)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
At least two options are required; each can be up to
|
||||
{maxPollOptionCharacters.toLocaleString()} characters.
|
||||
</p>
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<label class="button button--small composer-upload">
|
||||
{uploading ? 'Uploading…' : 'Add photo'}
|
||||
@@ -148,11 +352,22 @@
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
disabled={uploading || attachments.length >= maxAttachments}
|
||||
disabled={uploading || showPoll || attachments.length >= maxAttachments}
|
||||
onchange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
aria-pressed={showPoll ? 'true' : 'false'}
|
||||
disabled={!showPoll && attachments.length > 0}
|
||||
title={attachments.length > 0 ? 'Remove attachments before adding a poll' : 'Add a poll'}
|
||||
onclick={togglePoll}
|
||||
>
|
||||
Poll
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
@@ -172,7 +387,12 @@
|
||||
|
||||
<span class="composer-counter" data-over={remaining < 0 ? 'true' : 'false'}>{remaining}</span>
|
||||
|
||||
<button class="button button--primary" type="submit" disabled={!canPost}>
|
||||
<button
|
||||
class="button button--primary"
|
||||
type="submit"
|
||||
disabled={!canPost}
|
||||
title="Submit (Command+Enter or Ctrl+Enter)"
|
||||
>
|
||||
{busy ? 'Posting…' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -24,4 +24,159 @@ describe('Composer', () => {
|
||||
visibility: 'public',
|
||||
})
|
||||
})
|
||||
|
||||
it('composes a multiple-choice poll without a backend', async () => {
|
||||
const postStatus = vi.fn().mockResolvedValue(status())
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me: account(),
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
polls: {
|
||||
max_options: 4,
|
||||
max_characters_per_option: 30,
|
||||
min_expiration: 300,
|
||||
max_expiration: 604_800,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
endpoints: { postStatus },
|
||||
})
|
||||
const view = render(Composer, {
|
||||
props: { initialText: 'Which old web feature should return?' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Poll' }))
|
||||
await fireEvent.input(view.getByRole('textbox', { name: 'Poll option 1' }), {
|
||||
target: { value: 'Guestbooks' },
|
||||
})
|
||||
await fireEvent.input(view.getByRole('textbox', { name: 'Poll option 2' }), {
|
||||
target: { value: 'Webrings' },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Add option' }))
|
||||
await fireEvent.input(view.getByRole('textbox', { name: 'Poll option 3' }), {
|
||||
target: { value: 'Blink tags' },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('checkbox', { name: 'Allow multiple choices' }))
|
||||
await fireEvent.change(view.getByLabelText('Keep open for'), {
|
||||
target: { value: '3600' },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Post Entry' }))
|
||||
|
||||
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||
expect(postStatus.mock.calls[0][1]).toMatchObject({
|
||||
status: 'Which old web feature should return?',
|
||||
poll: {
|
||||
options: ['Guestbooks', 'Webrings', 'Blink tags'],
|
||||
expires_in: 3600,
|
||||
multiple: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Command+Enter', { metaKey: true }],
|
||||
['Ctrl+Enter', { ctrlKey: true }],
|
||||
])('submits with %s from the focused compose field', async (_label, modifier) => {
|
||||
const postStatus = vi.fn().mockResolvedValue(status())
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { postStatus },
|
||||
})
|
||||
const view = render(Composer, {
|
||||
props: { initialText: 'Keyboard-posted entry' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.keyDown(view.getByRole('textbox', { name: 'Entry text' }), {
|
||||
key: 'Enter',
|
||||
...modifier,
|
||||
})
|
||||
|
||||
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||
expect(postStatus.mock.calls[0][1].status).toBe('Keyboard-posted entry')
|
||||
})
|
||||
|
||||
it('uploads a pasted clipboard image and attaches it to the post', async () => {
|
||||
const pastedImage = new File(['image bytes'], 'pasted-image.png', { type: 'image/png' })
|
||||
const uploadMedia = vi.fn().mockResolvedValue({
|
||||
id: 'pasted-media',
|
||||
type: 'image',
|
||||
url: 'https://media.example/pasted-image.png',
|
||||
preview_url: 'https://media.example/pasted-image-preview.png',
|
||||
description: null,
|
||||
})
|
||||
const postStatus = vi.fn().mockResolvedValue(status())
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me: account(),
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Test server',
|
||||
version: '1.0.0',
|
||||
configuration: { statuses: { max_media_attachments: 4 } },
|
||||
},
|
||||
}),
|
||||
endpoints: { uploadMedia, postStatus },
|
||||
})
|
||||
const view = render(Composer, {
|
||||
props: { initialText: 'A pasted picture' },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
const textarea = view.getByRole('textbox', { name: 'Entry text' })
|
||||
|
||||
await fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{
|
||||
kind: 'file',
|
||||
type: 'image/png',
|
||||
getAsFile: () => pastedImage,
|
||||
},
|
||||
],
|
||||
files: [pastedImage],
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(uploadMedia).toHaveBeenCalledOnce())
|
||||
expect(uploadMedia.mock.calls[0][1]).toBe(pastedImage)
|
||||
expect(view.container.querySelector('.composer-attachment img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://media.example/pasted-image-preview.png',
|
||||
)
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Post Entry' }))
|
||||
|
||||
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||
expect(postStatus.mock.calls[0][1].media_ids).toEqual(['pasted-media'])
|
||||
})
|
||||
|
||||
it('leaves ordinary text-only paste alone', async () => {
|
||||
const uploadMedia = vi.fn()
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { uploadMedia },
|
||||
})
|
||||
const view = render(Composer, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
const textarea = view.getByRole('textbox', { name: 'Entry text' })
|
||||
|
||||
const wasNotCancelled = await fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }],
|
||||
files: [],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wasNotCancelled).toBe(true)
|
||||
expect(uploadMedia).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,42 +1,175 @@
|
||||
<script lang="ts">
|
||||
/** Read-only poll results. Voting needs a write scope and a UI of its own. */
|
||||
/** Mastodon/Pleroma poll choices, voting and results. */
|
||||
import { untrack } from 'svelte'
|
||||
import type { Poll } from '$lib/api/types'
|
||||
import { relativeTime } from '$lib/util/time'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { formatCount } from '$lib/util/profile'
|
||||
|
||||
interface Props {
|
||||
poll: Poll
|
||||
authorId?: string
|
||||
onupdate?: (poll: Poll) => void
|
||||
}
|
||||
|
||||
let { poll }: Props = $props()
|
||||
let { poll, authorId, onupdate }: Props = $props()
|
||||
const { endpoints, session } = useAppServices()
|
||||
|
||||
const total = $derived(poll.votes_count || 0)
|
||||
function pollSignature(value: Poll): string {
|
||||
return [
|
||||
value.id,
|
||||
value.expired,
|
||||
value.voted,
|
||||
value.votes_count,
|
||||
value.own_votes?.join(',') ?? '',
|
||||
value.options.map((option) => option.votes_count ?? '').join(','),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
let incomingSignature = $state(untrack(() => pollSignature(poll)))
|
||||
let currentPoll = $state(untrack(() => poll))
|
||||
let selectedChoices = $state<number[]>([])
|
||||
let singleChoice = $state<number | null>(null)
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const total = $derived(currentPoll.votes_count || 0)
|
||||
const isAuthor = $derived(Boolean(authorId && session.me?.id === authorId))
|
||||
const showResults = $derived(currentPoll.expired || Boolean(currentPoll.voted) || isAuthor)
|
||||
const canChoose = $derived(
|
||||
session.signedIn && !isAuthor && !currentPoll.expired && !currentPoll.voted && !busy,
|
||||
)
|
||||
const choices = $derived(
|
||||
currentPoll.multiple
|
||||
? selectedChoices
|
||||
: singleChoice === null
|
||||
? []
|
||||
: [singleChoice],
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const next = poll
|
||||
const signature = pollSignature(next)
|
||||
if (signature !== incomingSignature) {
|
||||
incomingSignature = signature
|
||||
currentPoll = next
|
||||
selectedChoices = []
|
||||
singleChoice = null
|
||||
error = null
|
||||
}
|
||||
})
|
||||
|
||||
function share(votes: number | null): number {
|
||||
if (!total || votes === null) return 0
|
||||
return Math.round((votes / total) * 100)
|
||||
}
|
||||
|
||||
function closesIn(value: string): string {
|
||||
const seconds = Math.max(0, Math.ceil((new Date(value).getTime() - Date.now()) / 1000))
|
||||
const units: Array<[string, number]> = [
|
||||
['day', 86_400],
|
||||
['hour', 3_600],
|
||||
['minute', 60],
|
||||
]
|
||||
for (const [label, size] of units) {
|
||||
if (seconds >= size) {
|
||||
const count = Math.ceil(seconds / size)
|
||||
return `${count} ${label}${count === 1 ? '' : 's'}`
|
||||
}
|
||||
}
|
||||
return seconds > 0 ? `${seconds} second${seconds === 1 ? '' : 's'}` : 'soon'
|
||||
}
|
||||
|
||||
async function submitVote(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (!canChoose || choices.length === 0) return
|
||||
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
const updated = await endpoints.votePoll(session.api, currentPoll.id, choices)
|
||||
currentPoll = updated
|
||||
selectedChoices = []
|
||||
singleChoice = null
|
||||
onupdate?.(updated)
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not submit your vote.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="poll" data-expired={poll.expired ? 'true' : 'false'}>
|
||||
{#each poll.options as option, index (index)}
|
||||
<div class="poll-option" data-own-vote={poll.own_votes?.includes(index) ? 'true' : 'false'}>
|
||||
<div class="poll-option-label">
|
||||
<span class="poll-option-title">{option.title}</span>
|
||||
<span class="poll-option-share">{share(option.votes_count)}%</span>
|
||||
<div
|
||||
class="poll"
|
||||
data-expired={currentPoll.expired ? 'true' : 'false'}
|
||||
data-voted={currentPoll.voted ? 'true' : 'false'}
|
||||
data-multiple={currentPoll.multiple ? 'true' : 'false'}
|
||||
>
|
||||
{#if showResults}
|
||||
{#each currentPoll.options as option, index (index)}
|
||||
<div class="poll-option" data-own-vote={currentPoll.own_votes?.includes(index) ? 'true' : 'false'}>
|
||||
<div class="poll-option-label">
|
||||
<span class="poll-option-title">
|
||||
{#if currentPoll.own_votes?.includes(index)}
|
||||
<span class="poll-own-vote" aria-label="Your vote">✓</span>
|
||||
{/if}
|
||||
{option.title}
|
||||
</span>
|
||||
<span class="poll-option-share">
|
||||
{option.votes_count === null ? '—' : `${share(option.votes_count)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div class="poll-option-bar" aria-hidden="true">
|
||||
<span class="poll-option-fill" style={`width: ${share(option.votes_count)}%`}></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-option-bar">
|
||||
<span class="poll-option-fill" style="width: {share(option.votes_count)}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<form class="poll-vote-form" onsubmit={submitVote}>
|
||||
<fieldset class="poll-choices" disabled={!canChoose}>
|
||||
<legend class="visually-hidden">
|
||||
{currentPoll.multiple ? 'Choose one or more poll options' : 'Choose one poll option'}
|
||||
</legend>
|
||||
{#each currentPoll.options as option, index (index)}
|
||||
<label class="poll-choice">
|
||||
{#if currentPoll.multiple}
|
||||
<input type="checkbox" value={index} bind:group={selectedChoices} />
|
||||
{:else}
|
||||
<input type="radio" name={`poll-${currentPoll.id}`} value={index} bind:group={singleChoice} />
|
||||
{/if}
|
||||
<span>{option.title}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
{#if session.signedIn}
|
||||
<div class="poll-vote-actions">
|
||||
<button class="button button--small button--primary" type="submit" disabled={!canChoose || choices.length === 0}>
|
||||
{busy ? 'Voting…' : 'Vote'}
|
||||
</button>
|
||||
{#if currentPoll.multiple}
|
||||
<span class="field-hint">Choose one or more.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="poll-sign-in"><a href="#/login">Sign in to vote</a></p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if isAuthor && !currentPoll.expired}
|
||||
<p class="poll-notice">You created this poll.</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<p class="poll-meta">
|
||||
{formatCount(total)} vote{total === 1 ? '' : 's'}
|
||||
{#if poll.expired}
|
||||
{#if currentPoll.expired}
|
||||
· closed
|
||||
{:else if poll.expires_at}
|
||||
· closes {relativeTime(poll.expires_at).replace(' ago', ' from now')}
|
||||
{:else if currentPoll.expires_at}
|
||||
· closes in {closesIn(currentPoll.expires_at)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import type { ApiClient } from '$lib/api/client'
|
||||
import type { Poll } from '$lib/api/types'
|
||||
import { account, session, testServices } from '$test/fixtures'
|
||||
import PollView from './PollView.svelte'
|
||||
|
||||
function poll(overrides: Partial<Poll> = {}): Poll {
|
||||
return {
|
||||
id: 'poll-1',
|
||||
expires_at: '2099-01-01T00:00:00.000Z',
|
||||
expired: false,
|
||||
multiple: false,
|
||||
votes_count: 0,
|
||||
options: [
|
||||
{ title: 'Cats', votes_count: null },
|
||||
{ title: 'Dogs', votes_count: null },
|
||||
{ title: 'Both', votes_count: null },
|
||||
],
|
||||
emojis: [],
|
||||
voted: false,
|
||||
own_votes: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function signedServices(
|
||||
votePoll: (api: ApiClient, id: string, choices: number[]) => Promise<Poll>,
|
||||
) {
|
||||
return testServices({
|
||||
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||
endpoints: { votePoll },
|
||||
})
|
||||
}
|
||||
|
||||
describe('PollView', () => {
|
||||
it('submits one selected index and immediately shows returned results', async () => {
|
||||
const updated = poll({
|
||||
voted: true,
|
||||
votes_count: 3,
|
||||
own_votes: [1],
|
||||
options: [
|
||||
{ title: 'Cats', votes_count: 1 },
|
||||
{ title: 'Dogs', votes_count: 2 },
|
||||
{ title: 'Both', votes_count: 0 },
|
||||
],
|
||||
})
|
||||
const votePoll = vi.fn().mockResolvedValue(updated)
|
||||
const onupdate = vi.fn()
|
||||
const view = render(PollView, {
|
||||
props: { poll: poll(), authorId: 'someone-else', onupdate },
|
||||
context: new Map([[APP_SERVICES, signedServices(votePoll)]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(view.getByRole('radio', { name: 'Dogs' }))
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Vote' }))
|
||||
|
||||
await waitFor(() => expect(votePoll).toHaveBeenCalledOnce())
|
||||
expect(votePoll.mock.calls[0][1]).toBe('poll-1')
|
||||
expect(votePoll.mock.calls[0][2]).toEqual([1])
|
||||
expect(onupdate).toHaveBeenCalledWith(updated)
|
||||
expect(await view.findByText('67%')).toBeInTheDocument()
|
||||
expect(view.getByLabelText('Your vote')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits every selected index for a multiple-choice poll', async () => {
|
||||
const votePoll = vi.fn().mockResolvedValue(
|
||||
poll({ multiple: true, voted: true, own_votes: [0, 2] }),
|
||||
)
|
||||
const view = render(PollView, {
|
||||
props: { poll: poll({ multiple: true }), authorId: 'someone-else' },
|
||||
context: new Map([[APP_SERVICES, signedServices(votePoll)]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(view.getByRole('checkbox', { name: 'Cats' }))
|
||||
await fireEvent.click(view.getByRole('checkbox', { name: 'Both' }))
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Vote' }))
|
||||
|
||||
await waitFor(() => expect(votePoll).toHaveBeenCalledOnce())
|
||||
expect(votePoll.mock.calls[0][2]).toEqual([0, 2])
|
||||
})
|
||||
|
||||
it('shows open poll options to signed-out readers without enabling a vote', () => {
|
||||
const view = render(PollView, {
|
||||
props: { poll: poll(), authorId: 'someone-else' },
|
||||
context: new Map([[APP_SERVICES, testServices()]]),
|
||||
})
|
||||
|
||||
expect(view.getByRole('radio', { name: 'Cats' })).toBeDisabled()
|
||||
expect(view.getByRole('link', { name: 'Sign in to vote' })).toHaveAttribute('href', '#/login')
|
||||
expect(view.queryByRole('button', { name: 'Vote' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Quiet background notification polling plus a transient, clickable stack.
|
||||
* Polls are serialized: the next delay begins only after the previous
|
||||
* request settles, so a slow or offline server can never accumulate calls.
|
||||
*/
|
||||
import type { ApiClient } from '$lib/api/client'
|
||||
import type { Notification } from '$lib/api/types'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import {
|
||||
NOTIFICATION_DISMISS_AFTER_MS,
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
NotificationTracker,
|
||||
presentNotification,
|
||||
type NotificationPresentation,
|
||||
} from '$lib/notifications'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
|
||||
interface Props {
|
||||
pollIntervalMs?: number
|
||||
dismissAfterMs?: number
|
||||
maxToasts?: number
|
||||
}
|
||||
|
||||
interface Toast {
|
||||
notification: Notification
|
||||
presentation: NotificationPresentation
|
||||
}
|
||||
|
||||
let {
|
||||
pollIntervalMs = NOTIFICATION_POLL_INTERVAL_MS,
|
||||
dismissAfterMs = NOTIFICATION_DISMISS_AFTER_MS,
|
||||
maxToasts = 4,
|
||||
}: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
const tracker = new NotificationTracker()
|
||||
const dismissTimers = new Map<string, number>()
|
||||
|
||||
let toasts = $state<Toast[]>([])
|
||||
let pollTimer: number | undefined
|
||||
let generation = 0
|
||||
|
||||
$effect(() => {
|
||||
const signedIn = session.signedIn
|
||||
// Tracking the identity restarts the baseline after sign-in or account/
|
||||
// server changes, so one account can never see another account's toasts.
|
||||
const identity = `${session.host}:${session.me?.id ?? ''}:${session.token ?? ''}`
|
||||
const api = session.api
|
||||
identity
|
||||
|
||||
const currentGeneration = ++generation
|
||||
clearPollTimer()
|
||||
clearToasts()
|
||||
tracker.reset()
|
||||
|
||||
if (!signedIn) return
|
||||
void poll(api, currentGeneration)
|
||||
|
||||
return () => {
|
||||
if (generation === currentGeneration) generation += 1
|
||||
clearPollTimer()
|
||||
clearDismissTimers()
|
||||
}
|
||||
})
|
||||
|
||||
async function poll(api: ApiClient, currentGeneration: number): Promise<void> {
|
||||
try {
|
||||
const page = await endpoints.fetchNotifications(api, {
|
||||
since_id: tracker.cursor,
|
||||
limit: 20,
|
||||
})
|
||||
if (currentGeneration !== generation) return
|
||||
|
||||
// API pages are newest-first. Add oldest-to-newest while prepending so
|
||||
// the newest toast finishes at the top of the stack.
|
||||
for (const notification of [...tracker.ingest(page.items)].reverse()) {
|
||||
show(notification)
|
||||
}
|
||||
} catch {
|
||||
// Polling is ambient: offline/CORS errors must not disturb the page.
|
||||
}
|
||||
|
||||
if (currentGeneration === generation) {
|
||||
pollTimer = window.setTimeout(
|
||||
() => void poll(api, currentGeneration),
|
||||
Math.max(1, pollIntervalMs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function show(notification: Notification): void {
|
||||
const toast: Toast = {
|
||||
notification,
|
||||
presentation: presentNotification(notification),
|
||||
}
|
||||
const next = [toast, ...toasts.filter((item) => item.notification.id !== notification.id)]
|
||||
const dropped = next.slice(Math.max(1, maxToasts))
|
||||
toasts = next.slice(0, Math.max(1, maxToasts))
|
||||
|
||||
for (const item of dropped) clearDismissTimer(item.notification.id)
|
||||
clearDismissTimer(notification.id)
|
||||
dismissTimers.set(
|
||||
notification.id,
|
||||
window.setTimeout(
|
||||
() => dismiss(notification.id),
|
||||
Math.max(1, dismissAfterMs),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function dismiss(id: string): void {
|
||||
clearDismissTimer(id)
|
||||
toasts = toasts.filter((toast) => toast.notification.id !== id)
|
||||
}
|
||||
|
||||
function clearDismissTimer(id: string): void {
|
||||
const timer = dismissTimers.get(id)
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
dismissTimers.delete(id)
|
||||
}
|
||||
|
||||
function clearToasts(): void {
|
||||
clearDismissTimers()
|
||||
toasts = []
|
||||
}
|
||||
|
||||
function clearDismissTimers(): void {
|
||||
for (const timer of dismissTimers.values()) window.clearTimeout(timer)
|
||||
dismissTimers.clear()
|
||||
}
|
||||
|
||||
function clearPollTimer(): void {
|
||||
if (pollTimer !== undefined) window.clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="notification-toast-stack" aria-label="New notifications" aria-live="polite">
|
||||
{#each toasts as toast (toast.notification.id)}
|
||||
<a
|
||||
class="notification-toast"
|
||||
href={toast.presentation.href}
|
||||
data-kind={toast.notification.type}
|
||||
data-account={toast.notification.account.acct}
|
||||
onclick={() => dismiss(toast.notification.id)}
|
||||
>
|
||||
<Avatar account={toast.notification.account} plain class="notification-toast-avatar" />
|
||||
<span class="notification-toast-body">
|
||||
<span class="notification-toast-message">
|
||||
<strong>{toast.presentation.actor}</strong>
|
||||
{toast.presentation.message}
|
||||
</span>
|
||||
{#if toast.presentation.excerpt}
|
||||
<span class="notification-toast-excerpt">{toast.presentation.excerpt}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</aside>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, render } from '@testing-library/svelte'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import type { Notification } from '$lib/api/types'
|
||||
import { account, session, status, testServices } from '$test/fixtures'
|
||||
import NotificationToasts from './NotificationToasts.svelte'
|
||||
|
||||
function notification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: 'notification-1',
|
||||
type: 'mention',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
account: account({ id: 'bob', display_name: 'Bob', acct: 'bob' }),
|
||||
status: status({ id: 'mentioned-entry', content: '<p>A new mention</p>' }),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('NotificationToasts', () => {
|
||||
it('polls quietly, shows only new notifications, then dismisses them', async () => {
|
||||
vi.useFakeTimers()
|
||||
const oldNotification = notification({ id: 'old-notification' })
|
||||
const newNotification = notification({ id: 'new-notification' })
|
||||
const fetchNotifications = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: [oldNotification], links: {} })
|
||||
.mockResolvedValueOnce({
|
||||
items: [newNotification, oldNotification],
|
||||
links: {},
|
||||
})
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me: account(),
|
||||
signedIn: true,
|
||||
}),
|
||||
endpoints: { fetchNotifications },
|
||||
})
|
||||
const view = render(NotificationToasts, {
|
||||
props: { pollIntervalMs: 1_000, dismissAfterMs: 500 },
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(fetchNotifications).toHaveBeenCalledOnce()
|
||||
expect(view.queryByText('A new mention')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
|
||||
expect(fetchNotifications).toHaveBeenCalledTimes(2)
|
||||
expect(fetchNotifications.mock.calls[1][1]).toMatchObject({
|
||||
since_id: 'old-notification',
|
||||
limit: 20,
|
||||
})
|
||||
expect(view.getByRole('link', { name: /Bob mentioned you in an entry/ })).toHaveAttribute(
|
||||
'href',
|
||||
'#/blog/mentioned-entry',
|
||||
)
|
||||
expect(view.getByText('A new mention')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
})
|
||||
expect(view.queryByText('A new mention')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Status HTML renderer with Pleroma-FE-compatible MFM effects.
|
||||
*
|
||||
* This consumes the server-rendered FEP-c16b spans found in `status.content`;
|
||||
* it intentionally does not parse raw `$[...]` source syntax.
|
||||
*/
|
||||
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
|
||||
import { renderMfmHtml } from '$lib/util/mfm'
|
||||
|
||||
interface Props {
|
||||
html: string | null | undefined
|
||||
emojis?: CustomEmoji[]
|
||||
mentions?: StatusMention[]
|
||||
tags?: StatusTag[]
|
||||
inline?: boolean
|
||||
class?: string
|
||||
lang?: string | null
|
||||
/** Pleroma-FE defaults to pausing animation until hover. */
|
||||
pause?: boolean
|
||||
scale?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
html,
|
||||
emojis,
|
||||
mentions,
|
||||
tags,
|
||||
inline = false,
|
||||
class: extraClass = '',
|
||||
lang,
|
||||
pause = true,
|
||||
scale = false,
|
||||
}: Props = $props()
|
||||
|
||||
const rendered = $derived(
|
||||
renderMfmHtml(html, { emojis, mentions, tags, inline, pause, scale }),
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if rendered}
|
||||
<div
|
||||
class="rich-text {extraClass}"
|
||||
class:rich-text--inline={inline}
|
||||
lang={lang ?? undefined}
|
||||
dir="auto"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMfmHtml -->
|
||||
{@html rendered}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import MfmContent from './MfmContent.svelte'
|
||||
|
||||
function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement {
|
||||
const element = view.getByText(text)
|
||||
expect(element).toHaveClass('mfm')
|
||||
return element
|
||||
}
|
||||
|
||||
describe('MfmContent', () => {
|
||||
it('renders Pleroma server-produced MFM with its paused-until-hover default', () => {
|
||||
const view = render(MfmContent, {
|
||||
props: {
|
||||
html: '<p>Hello <span class="mfm-spin" data-mfm-speed="0.5s" data-mfm-alternate>world</span></p>',
|
||||
},
|
||||
})
|
||||
|
||||
const element = mfm(view, 'world')
|
||||
expect(element).toHaveClass('-pause')
|
||||
expect(element).toHaveAttribute('data-mfm-operator', 'spin')
|
||||
expect(element.style.animationName).toBe('mfm-spin')
|
||||
expect(element.style.animationDuration).toBe('0.5s')
|
||||
expect(element.style.animationDirection).toBe('alternate')
|
||||
expect(element.style.animationIterationCount).toBe('infinite')
|
||||
})
|
||||
|
||||
it('supports Pleroma-FE pause and scale options without a backend', () => {
|
||||
const view = render(MfmContent, {
|
||||
props: {
|
||||
html: '<span class="mfm-x2">large</span>',
|
||||
pause: false,
|
||||
scale: true,
|
||||
},
|
||||
})
|
||||
|
||||
const element = mfm(view, 'large')
|
||||
expect(element).not.toHaveClass('-pause')
|
||||
expect(element).toHaveClass('-scale')
|
||||
expect(element).toHaveAttribute('data-mfm-operator', 'x2')
|
||||
})
|
||||
|
||||
it('preserves RichText sanitizing, custom emoji, and local link rewriting', () => {
|
||||
const view = render(MfmContent, {
|
||||
props: {
|
||||
html: '<script>bad()</script><p><a href="https://example.test/@alice">@alice</a> :party:</p>',
|
||||
mentions: [
|
||||
{
|
||||
id: 'account-1',
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
url: 'https://example.test/@alice',
|
||||
},
|
||||
],
|
||||
emojis: [
|
||||
{
|
||||
shortcode: 'party',
|
||||
url: 'https://example.test/emoji/party.png',
|
||||
static_url: 'https://example.test/emoji/party.png',
|
||||
visible_in_picker: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(view.container.querySelector('script')).not.toBeInTheDocument()
|
||||
expect(view.getByRole('link', { name: '@alice' })).toHaveAttribute('href', '#/@alice')
|
||||
expect(view.getByRole('img', { name: ':party:' })).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.test/emoji/party.png',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Public-facing profile data. Account behavior and private preferences belong
|
||||
* in a separate settings editor.
|
||||
*/
|
||||
import { untrack } from 'svelte'
|
||||
import type { CredentialAccount } from '$lib/api/types'
|
||||
import { isEgregoros, publicProfileCapabilities } from '$lib/api/capabilities'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { profileFieldLimits } from '$lib/stores/theme.svelte'
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
|
||||
interface EditableField {
|
||||
id: number
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type ImageMode = 'keep' | 'replace' | 'remove'
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
let fieldSerial = 0
|
||||
|
||||
function rawFields(account: CredentialAccount | null): Array<{ name: string; value: string }> {
|
||||
if (!account) return []
|
||||
if (account.source?.fields) {
|
||||
return account.source.fields.map(({ name, value }) => ({ name, value }))
|
||||
}
|
||||
return account.fields.map(({ name, value }) => ({ name, value: toPlainText(value) }))
|
||||
}
|
||||
|
||||
function internalFields(account: CredentialAccount | null): Array<{ name: string; value: string }> {
|
||||
return rawFields(account).filter((field) => field.name.trim().toLowerCase().startsWith('plspace:'))
|
||||
}
|
||||
|
||||
function publicFields(account: CredentialAccount | null): EditableField[] {
|
||||
return rawFields(account)
|
||||
.filter((field) => !field.name.trim().toLowerCase().startsWith('plspace:'))
|
||||
.map((field) => ({ id: ++fieldSerial, ...field }))
|
||||
}
|
||||
|
||||
const initial = untrack(() => session.me)
|
||||
let displayName = $state(initial?.display_name ?? '')
|
||||
let note = $state(initial?.source?.note ?? toPlainText(initial?.note ?? ''))
|
||||
let fields = $state<EditableField[]>(publicFields(initial))
|
||||
let actorType = $state<'Person' | 'Service' | 'Group'>(
|
||||
initial?.source?.pleroma?.actor_type ?? (initial?.bot ? 'Service' : 'Person'),
|
||||
)
|
||||
let birthday = $state(initial?.pleroma?.birthday ?? '')
|
||||
let showBirthday = $state(Boolean(initial?.source?.pleroma?.show_birthday))
|
||||
let avatarDescription = $state(
|
||||
initial?.avatar_description ?? initial?.pleroma?.avatar_description ?? '',
|
||||
)
|
||||
let headerDescription = $state(
|
||||
initial?.header_description ?? initial?.pleroma?.header_description ?? '',
|
||||
)
|
||||
|
||||
let avatarMode = $state<ImageMode>('keep')
|
||||
let headerMode = $state<ImageMode>('keep')
|
||||
let backgroundMode = $state<ImageMode>('keep')
|
||||
let avatarFile = $state<File | null>(null)
|
||||
let headerFile = $state<File | null>(null)
|
||||
let backgroundFile = $state<File | null>(null)
|
||||
let busy = $state(false)
|
||||
let saved = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
const isPleroma = $derived(Boolean(session.instance?.pleroma))
|
||||
const isEgregorosServer = $derived(isEgregoros(session.instance))
|
||||
const capabilities = $derived(publicProfileCapabilities(session.instance))
|
||||
const limits = $derived(profileFieldLimits(session.instance))
|
||||
const reservedFields = $derived(internalFields(session.me).length)
|
||||
const availablePublicFields = $derived(
|
||||
capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0,
|
||||
)
|
||||
const canAddField = $derived(fields.length < availablePublicFields)
|
||||
const fieldsValid = $derived(
|
||||
!capabilities.fields ||
|
||||
(fields.filter((field) => field.name.trim()).length <= availablePublicFields &&
|
||||
fields.every(
|
||||
(field) =>
|
||||
field.name.length <= limits.nameLength && field.value.length <= limits.valueLength,
|
||||
)),
|
||||
)
|
||||
const canSave = $derived(Boolean(displayName.trim()) && fieldsValid && !busy)
|
||||
|
||||
function addField(): void {
|
||||
if (!canAddField) return
|
||||
fields = [...fields, { id: ++fieldSerial, name: '', value: '' }]
|
||||
}
|
||||
|
||||
function removeField(id: number): void {
|
||||
fields = fields.filter((field) => field.id !== id)
|
||||
}
|
||||
|
||||
function chooseImage(
|
||||
kind: 'avatar' | 'header' | 'background',
|
||||
event: Event,
|
||||
): void {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null
|
||||
if (!file) return
|
||||
if (kind === 'avatar') {
|
||||
avatarFile = file
|
||||
avatarMode = 'replace'
|
||||
} else if (kind === 'header') {
|
||||
headerFile = file
|
||||
headerMode = 'replace'
|
||||
} else {
|
||||
backgroundFile = file
|
||||
backgroundMode = 'replace'
|
||||
}
|
||||
}
|
||||
|
||||
function imageValue(mode: ImageMode, file: File | null): File | '' | undefined {
|
||||
if (mode === 'remove') return ''
|
||||
if (mode === 'replace' && file) return file
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resetFrom(account: CredentialAccount): void {
|
||||
displayName = account.display_name
|
||||
note = account.source?.note ?? toPlainText(account.note)
|
||||
fields = publicFields(account)
|
||||
actorType = account.source?.pleroma?.actor_type ?? (account.bot ? 'Service' : 'Person')
|
||||
birthday = account.pleroma?.birthday ?? ''
|
||||
showBirthday = Boolean(account.source?.pleroma?.show_birthday)
|
||||
avatarDescription =
|
||||
account.avatar_description ?? account.pleroma?.avatar_description ?? ''
|
||||
headerDescription =
|
||||
account.header_description ?? account.pleroma?.header_description ?? ''
|
||||
avatarMode = 'keep'
|
||||
headerMode = 'keep'
|
||||
backgroundMode = 'keep'
|
||||
avatarFile = null
|
||||
headerFile = null
|
||||
backgroundFile = null
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault()
|
||||
if (!session.me || !canSave) return
|
||||
|
||||
busy = true
|
||||
saved = false
|
||||
error = null
|
||||
try {
|
||||
// Read these at save time so a CSS edit made while this form is open is
|
||||
// never overwritten by an older copy of the hidden storage fields.
|
||||
const hidden = capabilities.fields ? internalFields(session.me) : []
|
||||
const visible = capabilities.fields
|
||||
? fields
|
||||
.filter((field) => field.name.trim())
|
||||
.map(({ name, value }) => ({ name: name.trim(), value }))
|
||||
: []
|
||||
const updated = await endpoints.updatePublicProfile(session.api, {
|
||||
displayName: displayName.trim(),
|
||||
note,
|
||||
fields: capabilities.fields ? [...visible, ...hidden] : undefined,
|
||||
avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined,
|
||||
header: capabilities.header ? imageValue(headerMode, headerFile) : undefined,
|
||||
background: isPleroma ? imageValue(backgroundMode, backgroundFile) : undefined,
|
||||
avatarDescription: isPleroma ? avatarDescription : undefined,
|
||||
headerDescription: isPleroma ? headerDescription : undefined,
|
||||
bot: capabilities.identity && !isPleroma ? actorType === 'Service' : undefined,
|
||||
actorType: capabilities.identity && isPleroma ? actorType : undefined,
|
||||
birthday: capabilities.identity && isPleroma ? birthday : undefined,
|
||||
showBirthday: capabilities.identity && isPleroma ? showBirthday : undefined,
|
||||
})
|
||||
session.me = updated
|
||||
resetFrom(updated)
|
||||
saved = true
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not update your profile.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !session.signedIn || !session.me}
|
||||
<p class="empty-note"><a href="#/login">Sign in</a> to edit your public profile.</p>
|
||||
{:else}
|
||||
<form class="profile-editor" onsubmit={save}>
|
||||
<p class="notice">
|
||||
Everything in this form is public-facing and may be federated to other servers.
|
||||
</p>
|
||||
{#if isEgregorosServer}
|
||||
<p class="notice">
|
||||
Egregoros currently exposes display name, bio, and profile photo through its Mastodon API.
|
||||
plspace hides the other profile controls because that server would silently ignore them.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}<p class="error-note" role="alert">{error}</p>{/if}
|
||||
{#if saved}<p class="notice" role="status">Your public profile was updated.</p>{/if}
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="profile-display-name">Display name</label>
|
||||
<input
|
||||
id="profile-display-name"
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="profile-bio">About me / bio</label>
|
||||
<textarea id="profile-bio" class="field-input profile-editor-bio" bind:value={note} rows="7"></textarea>
|
||||
<p class="field-hint">Your server may support plain text, Markdown or other formatting here.</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="profile-editor-images">
|
||||
<legend>Profile images</legend>
|
||||
|
||||
<div class="profile-editor-image">
|
||||
<img src={session.me.avatar} alt="Current avatar" />
|
||||
<div>
|
||||
<strong>Profile photo</strong>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
aria-label="Choose a new profile photo"
|
||||
onchange={(event) => chooseImage('avatar', event)}
|
||||
/>
|
||||
{#if capabilities.removeAvatar}
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
onclick={() => {
|
||||
avatarMode = 'remove'
|
||||
avatarFile = null
|
||||
}}
|
||||
>Remove photo</button>
|
||||
{/if}
|
||||
{#if avatarMode === 'replace' && avatarFile}<span class="field-hint">{avatarFile.name}</span>{/if}
|
||||
{#if avatarMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if capabilities.header}
|
||||
<div class="profile-editor-image profile-editor-image--wide">
|
||||
<img src={session.me.header} alt="Current banner" />
|
||||
<div>
|
||||
<strong>Profile banner</strong>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
aria-label="Choose a new profile banner"
|
||||
onchange={(event) => chooseImage('header', event)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
onclick={() => {
|
||||
headerMode = 'remove'
|
||||
headerFile = null
|
||||
}}
|
||||
>Remove banner</button>
|
||||
{#if headerMode === 'replace' && headerFile}<span class="field-hint">{headerFile.name}</span>{/if}
|
||||
{#if headerMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isPleroma}
|
||||
<div class="profile-editor-image profile-editor-image--wide">
|
||||
{#if session.me.pleroma?.background_image}
|
||||
<img src={session.me.pleroma.background_image} alt="Current profile background" />
|
||||
{:else}
|
||||
<span class="profile-editor-image-placeholder">No background</span>
|
||||
{/if}
|
||||
<div>
|
||||
<strong>Profile background</strong>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
aria-label="Choose a new profile background"
|
||||
onchange={(event) => chooseImage('background', event)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
onclick={() => {
|
||||
backgroundMode = 'remove'
|
||||
backgroundFile = null
|
||||
}}
|
||||
>Remove background</button>
|
||||
{#if backgroundMode === 'replace' && backgroundFile}<span class="field-hint">{backgroundFile.name}</span>{/if}
|
||||
{#if backgroundMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="profile-editor-description">
|
||||
<span class="field-label">Profile photo description</span>
|
||||
<input class="field-input" type="text" bind:value={avatarDescription} />
|
||||
</label>
|
||||
<label class="profile-editor-description">
|
||||
<span class="field-label">Banner description</span>
|
||||
<input class="field-input" type="text" bind:value={headerDescription} />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if capabilities.fields}
|
||||
<fieldset class="profile-editor-fields">
|
||||
<legend>Profile details</legend>
|
||||
<p class="field-hint">
|
||||
These become the Interests and Details rows on your profile. Hidden
|
||||
<code>plspace:</code> storage fields are preserved automatically.
|
||||
</p>
|
||||
{#each fields as field (field.id)}
|
||||
<div class="profile-editor-field">
|
||||
<label>
|
||||
<span class="visually-hidden">Field name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={field.name}
|
||||
maxlength={limits.nameLength}
|
||||
placeholder="Label, e.g. Music"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="visually-hidden">Field value</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={field.value}
|
||||
maxlength={limits.valueLength}
|
||||
placeholder="Value"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="button button--small"
|
||||
aria-label={`Remove ${field.name || 'empty'} profile field`}
|
||||
onclick={() => removeField(field.id)}
|
||||
>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" class="button button--small" disabled={!canAddField} onclick={addField}>
|
||||
Add profile detail
|
||||
</button>
|
||||
<span class="field-hint">{fields.length} of {availablePublicFields} public fields used.</span>
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.identity}
|
||||
<fieldset class="profile-editor-identity">
|
||||
<legend>Public identity</legend>
|
||||
{#if isPleroma}
|
||||
<label class="field">
|
||||
<span class="field-label">Account type</span>
|
||||
<select bind:value={actorType}>
|
||||
<option value="Person">Person</option>
|
||||
<option value="Service">Bot</option>
|
||||
<option value="Group">Group</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label>
|
||||
<span class="field-label">Birthday</span>
|
||||
<input type="date" bind:value={birthday} />
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" bind:checked={showBirthday} />
|
||||
Show my birthday publicly
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={actorType === 'Service'}
|
||||
onchange={(event) => (actorType = event.currentTarget.checked ? 'Service' : 'Person')}
|
||||
/>
|
||||
This is a bot account
|
||||
</label>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<div class="field-row">
|
||||
<button class="button button--primary" type="submit" disabled={!canSave}>
|
||||
{busy ? 'Saving…' : 'Save public profile'}
|
||||
</button>
|
||||
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import type { PublicProfileUpdate } from '$lib/api/endpoints'
|
||||
import type { CredentialAccount } from '$lib/api/types'
|
||||
import { account, session, testServices } from '$test/fixtures'
|
||||
import PublicProfileEditor from './PublicProfileEditor.svelte'
|
||||
|
||||
function credential(): CredentialAccount {
|
||||
const fields = [
|
||||
{ name: 'Website', value: 'https://old.example' },
|
||||
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
|
||||
]
|
||||
return {
|
||||
...account({
|
||||
display_name: 'Alice',
|
||||
note: '<p>Old bio</p>',
|
||||
avatar: 'https://media.example/avatar.png',
|
||||
header: 'https://media.example/header.png',
|
||||
fields,
|
||||
pleroma: {
|
||||
background_image: 'https://media.example/background.png',
|
||||
birthday: '1999-01-01',
|
||||
},
|
||||
}),
|
||||
source: {
|
||||
note: 'Old bio',
|
||||
fields,
|
||||
privacy: 'public',
|
||||
sensitive: false,
|
||||
language: null,
|
||||
pleroma: {
|
||||
actor_type: 'Person',
|
||||
show_birthday: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PublicProfileEditor', () => {
|
||||
it('edits public data and preserves hidden plspace fields with no backend', async () => {
|
||||
const me = credential()
|
||||
const updatePublicProfile = vi.fn(
|
||||
async (_api, update: PublicProfileUpdate): Promise<CredentialAccount> => ({
|
||||
...me,
|
||||
display_name: update.displayName ?? me.display_name,
|
||||
fields: update.fields ?? me.fields,
|
||||
source: {
|
||||
...me.source!,
|
||||
note: update.note ?? me.source!.note,
|
||||
fields: update.fields ?? me.source!.fields,
|
||||
pleroma: {
|
||||
...me.source!.pleroma,
|
||||
actor_type: update.actorType,
|
||||
show_birthday: update.showBirthday,
|
||||
},
|
||||
},
|
||||
pleroma: {
|
||||
...me.pleroma,
|
||||
birthday: update.birthday,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me,
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 10,
|
||||
profile_field_name_limit: 512,
|
||||
profile_field_value_limit: 2048,
|
||||
},
|
||||
},
|
||||
pleroma: {
|
||||
metadata: {
|
||||
fields_limits: { max_fields: 10, name_length: 512, value_length: 2048 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
endpoints: { updatePublicProfile },
|
||||
})
|
||||
const view = render(PublicProfileEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.queryByDisplayValue('plspace:css1')).not.toBeInTheDocument()
|
||||
await fireEvent.input(view.getByLabelText('Display name'), {
|
||||
target: { value: 'Alice Updated' },
|
||||
})
|
||||
await fireEvent.input(view.getByLabelText('About me / bio'), {
|
||||
target: { value: 'A new public bio' },
|
||||
})
|
||||
await fireEvent.input(view.getByPlaceholderText('Value'), {
|
||||
target: { value: 'https://new.example' },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Add profile detail' }))
|
||||
const labels = view.getAllByPlaceholderText('Label, e.g. Music')
|
||||
const values = view.getAllByPlaceholderText('Value')
|
||||
await fireEvent.input(labels[labels.length - 1], { target: { value: 'Music' } })
|
||||
await fireEvent.input(values[values.length - 1], { target: { value: 'Synthpop' } })
|
||||
await fireEvent.change(view.getByLabelText('Account type'), {
|
||||
target: { value: 'Group' },
|
||||
})
|
||||
await fireEvent.input(view.getByLabelText('Birthday'), {
|
||||
target: { value: '2000-02-03' },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('checkbox', { name: 'Show my birthday publicly' }))
|
||||
|
||||
const avatar = new File(['new avatar'], 'new-avatar.png', { type: 'image/png' })
|
||||
await fireEvent.change(view.getByLabelText('Choose a new profile photo'), {
|
||||
target: { files: [avatar] },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Save public profile' }))
|
||||
|
||||
await waitFor(() => expect(updatePublicProfile).toHaveBeenCalledOnce())
|
||||
const sent = updatePublicProfile.mock.calls[0][1]
|
||||
expect(sent).toMatchObject({
|
||||
displayName: 'Alice Updated',
|
||||
note: 'A new public bio',
|
||||
avatar,
|
||||
actorType: 'Group',
|
||||
birthday: '2000-02-03',
|
||||
showBirthday: true,
|
||||
fields: [
|
||||
{ name: 'Website', value: 'https://new.example' },
|
||||
{ name: 'Music', value: 'Synthpop' },
|
||||
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
|
||||
],
|
||||
})
|
||||
expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows and submits only the profile fields Egregoros exposes through its API', async () => {
|
||||
const me = credential()
|
||||
const updatePublicProfile = vi.fn().mockResolvedValue(me)
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me,
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Egregoros',
|
||||
version: 'egregoros/0.1.0',
|
||||
},
|
||||
}),
|
||||
endpoints: { updatePublicProfile },
|
||||
})
|
||||
const view = render(PublicProfileEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.getByText(/currently exposes display name, bio, and profile photo/i)).toBeInTheDocument()
|
||||
expect(view.queryByText('Profile banner')).not.toBeInTheDocument()
|
||||
expect(view.queryByRole('button', { name: 'Remove photo' })).not.toBeInTheDocument()
|
||||
expect(view.queryByRole('button', { name: 'Add profile detail' })).not.toBeInTheDocument()
|
||||
expect(view.queryByRole('checkbox', { name: 'This is a bot account' })).not.toBeInTheDocument()
|
||||
|
||||
await fireEvent.input(view.getByLabelText('Display name'), {
|
||||
target: { value: 'Alice on Egregoros' },
|
||||
})
|
||||
await fireEvent.input(view.getByLabelText('About me / bio'), {
|
||||
target: { value: 'An Egregoros bio' },
|
||||
})
|
||||
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' })
|
||||
await fireEvent.change(view.getByLabelText('Choose a new profile photo'), {
|
||||
target: { files: [avatar] },
|
||||
})
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Save public profile' }))
|
||||
|
||||
await waitFor(() => expect(updatePublicProfile).toHaveBeenCalledOnce())
|
||||
const sent = updatePublicProfile.mock.calls[0][1]
|
||||
expect(sent).toMatchObject({
|
||||
displayName: 'Alice on Egregoros',
|
||||
note: 'An Egregoros bio',
|
||||
avatar,
|
||||
})
|
||||
expect(sent.fields).toBeUndefined()
|
||||
expect(sent.header).toBeUndefined()
|
||||
expect(sent.bot).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Editor for CSS published through the signed-in account's profile fields.
|
||||
*
|
||||
* This is deliberately separate from Settings' viewer CSS: saving here is a
|
||||
* server mutation and changes what every plspace visitor sees on this profile.
|
||||
*/
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { publicProfileCapabilities } from '$lib/api/capabilities'
|
||||
import {
|
||||
profileFieldLimits,
|
||||
publishedCssFieldValues,
|
||||
publishedCssFromFields,
|
||||
replacePublishedCssFields,
|
||||
} from '$lib/stores/theme.svelte'
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
|
||||
let editing = $state(false)
|
||||
let draft = $state('')
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let saved = $state(false)
|
||||
let locallyPublishedCss = $state<string | null>(null)
|
||||
|
||||
const limits = $derived(profileFieldLimits(session.instance))
|
||||
const canPublish = $derived(publicProfileCapabilities(session.instance).fields)
|
||||
const currentCss = $derived(
|
||||
locallyPublishedCss ?? publishedCssFromFields(session.me?.fields),
|
||||
)
|
||||
const chunksNeeded = $derived(publishedCssFieldValues(draft, limits.valueLength).length)
|
||||
|
||||
function decodeFieldValue(value: string): string {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = value
|
||||
return container.textContent ?? ''
|
||||
}
|
||||
|
||||
/** Credential-source fields are raw; fall back to decoding rendered fields. */
|
||||
function currentFields(): Array<{ name: string; value: string }> {
|
||||
if (session.me?.source?.fields) {
|
||||
return session.me.source.fields.map(({ name, value }) => ({ name, value }))
|
||||
}
|
||||
return (session.me?.fields ?? []).map(({ name, value }) => ({
|
||||
name,
|
||||
value: decodeFieldValue(value),
|
||||
}))
|
||||
}
|
||||
|
||||
function beginEditing(): void {
|
||||
draft = currentCss
|
||||
error = null
|
||||
saved = false
|
||||
editing = true
|
||||
}
|
||||
|
||||
async function publish(css: string): Promise<void> {
|
||||
if (!session.me || busy) return
|
||||
busy = true
|
||||
error = null
|
||||
saved = false
|
||||
try {
|
||||
const fields = replacePublishedCssFields(currentFields(), css, limits)
|
||||
session.me = await endpoints.updateProfileFields(session.api, fields)
|
||||
locallyPublishedCss = css.trim()
|
||||
draft = locallyPublishedCss
|
||||
editing = false
|
||||
saved = true
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not update your profile CSS.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
if (!confirm('Remove the CSS published on your profile?')) return
|
||||
void publish('')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !session.signedIn || !session.me}
|
||||
<p class="empty-note">
|
||||
<a href="#/login">Sign in</a> to publish CSS on your profile.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="published-css-editor">
|
||||
<p class="notice">
|
||||
<strong>Public profile setting:</strong>
|
||||
saving here changes your profile for everyone who views it with plspace—not just this
|
||||
browser. Other clients may display the CSS as ordinary profile fields.
|
||||
</p>
|
||||
|
||||
{#if !canPublish}
|
||||
<p class="notice">
|
||||
This server does not expose profile fields through its Mastodon API, so plspace cannot
|
||||
publish profile CSS here. Your private browser-only CSS above still works.
|
||||
</p>
|
||||
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
|
||||
{:else if editing}
|
||||
<label class="field-label" for="published-css">CSS shown on your profile</label>
|
||||
<textarea
|
||||
id="published-css"
|
||||
class="css-editor"
|
||||
bind:value={draft}
|
||||
rows="16"
|
||||
spellcheck="false"
|
||||
aria-describedby="published-css-hint"
|
||||
></textarea>
|
||||
<p id="published-css-hint" class="field-hint">
|
||||
{draft.length.toLocaleString()} characters.
|
||||
{#if chunksNeeded > 1}
|
||||
plspace will store this in {chunksNeeded} ordered profile fields because
|
||||
{limits.valueLength.toLocaleString()} characters fit in each field on this server.
|
||||
{:else}
|
||||
This fits in one profile field.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field-row">
|
||||
<button
|
||||
type="button"
|
||||
class="button button--primary"
|
||||
disabled={busy || !draft.trim()}
|
||||
onclick={() => void publish(draft)}
|
||||
>
|
||||
{busy ? 'Publishing…' : 'Publish CSS to my profile'}
|
||||
</button>
|
||||
<button type="button" class="button" disabled={busy} onclick={() => (editing = false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
{#if currentCss}
|
||||
Your profile currently publishes
|
||||
<strong>{currentCss.length.toLocaleString()} characters</strong> of CSS.
|
||||
{:else}
|
||||
Your profile does not currently publish any CSS.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{:else if saved}
|
||||
<p class="notice" role="status">Your public profile CSS was updated.</p>
|
||||
{/if}
|
||||
|
||||
<div class="field-row">
|
||||
<button type="button" class="button button--primary" onclick={beginEditing}>
|
||||
{currentCss ? 'Edit published CSS' : 'Add CSS to my profile'}
|
||||
</button>
|
||||
{#if currentCss}
|
||||
<button type="button" class="button" disabled={busy} onclick={remove}>
|
||||
Remove published CSS
|
||||
</button>
|
||||
{/if}
|
||||
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import type { CredentialAccount } from '$lib/api/types'
|
||||
import { publishedCssFromFields } from '$lib/stores/theme.svelte'
|
||||
import { account, session, testServices } from '$test/fixtures'
|
||||
import PublishedCssEditor from './PublishedCssEditor.svelte'
|
||||
|
||||
function credential(fields: Array<{ name: string; value: string }>): CredentialAccount {
|
||||
return {
|
||||
...account({ fields }),
|
||||
source: {
|
||||
note: '',
|
||||
fields,
|
||||
privacy: 'public',
|
||||
sensitive: false,
|
||||
language: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PublishedCssEditor', () => {
|
||||
it('makes the public effect explicit and publishes without losing other fields', async () => {
|
||||
const me = credential([{ name: 'Website', value: 'https://example.test' }])
|
||||
const updateProfileFields = vi.fn(async (_api, fields: Array<{ name: string; value: string }>) =>
|
||||
credential(fields),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me,
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 10,
|
||||
max_profile_field_value_length: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
endpoints: { updateProfileFields },
|
||||
})
|
||||
const view = render(PublishedCssEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.getByText(/everyone who views it with plspace/i)).toBeInTheDocument()
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Add CSS to my profile' }))
|
||||
|
||||
const css = '.one { color: red; }\n.two { color: green; }'
|
||||
await fireEvent.input(view.getByRole('textbox', { name: 'CSS shown on your profile' }), {
|
||||
target: { value: css },
|
||||
})
|
||||
expect(view.getByText(/store this in 2 ordered profile fields/i)).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Publish CSS to my profile' }))
|
||||
await waitFor(() => expect(updateProfileFields).toHaveBeenCalledOnce())
|
||||
|
||||
const sent = updateProfileFields.mock.calls[0][1]
|
||||
expect(sent[0]).toEqual({ name: 'Website', value: 'https://example.test' })
|
||||
expect(sent.slice(1).map((field) => field.name)).toEqual([
|
||||
'plspace:css1',
|
||||
'plspace:css2',
|
||||
])
|
||||
expect(publishedCssFromFields(sent.slice(1))).toBe(css)
|
||||
expect(await view.findByText('Your public profile CSS was updated.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads and replaces existing CSS chunks', async () => {
|
||||
const me = credential([
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
{ name: 'plspace:css1', value: '.old {' },
|
||||
{ name: 'plspace:css2', value: ' color: red; }' },
|
||||
])
|
||||
const updateProfileFields = vi.fn(async (_api, fields: Array<{ name: string; value: string }>) =>
|
||||
credential(fields),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me, signedIn: true }),
|
||||
endpoints: { updateProfileFields },
|
||||
})
|
||||
const view = render(PublishedCssEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Edit published CSS' }))
|
||||
const editor = view.getByRole('textbox', { name: 'CSS shown on your profile' })
|
||||
expect(editor).toHaveValue('.old { color: red; }')
|
||||
|
||||
await fireEvent.input(editor, { target: { value: '.new { color: pink; }' } })
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Publish CSS to my profile' }))
|
||||
await waitFor(() => expect(updateProfileFields).toHaveBeenCalledOnce())
|
||||
|
||||
const sent = updateProfileFields.mock.calls[0][1]
|
||||
expect(sent[0]).toEqual({ name: 'Pronouns', value: 'they/them' })
|
||||
expect(sent.slice(1).map((field) => field.name)).toEqual(['plspace:css1'])
|
||||
expect(publishedCssFromFields(sent.slice(1))).toBe('.new { color: pink; }')
|
||||
})
|
||||
|
||||
it('does not offer publishing when the server has no profile-fields API', () => {
|
||||
const updateProfileFields = vi.fn()
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me: credential([]),
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Egregoros',
|
||||
version: 'egregoros/0.1.0',
|
||||
},
|
||||
}),
|
||||
endpoints: { updateProfileFields },
|
||||
})
|
||||
const view = render(PublishedCssEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.getByText(/does not expose profile fields through its Mastodon API/i)).toBeInTheDocument()
|
||||
expect(view.queryByRole('button', { name: 'Add CSS to my profile' })).not.toBeInTheDocument()
|
||||
expect(updateProfileFields).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { InstanceInfo } from './types'
|
||||
|
||||
export interface PublicProfileCapabilities {
|
||||
avatar: boolean
|
||||
removeAvatar: boolean
|
||||
header: boolean
|
||||
fields: boolean
|
||||
identity: boolean
|
||||
}
|
||||
|
||||
export function isEgregoros(instance: InstanceInfo | null | undefined): boolean {
|
||||
return /^egregoros\//i.test(instance?.version?.trim() ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Egregoros deliberately implements a smaller update_credentials surface than
|
||||
* Mastodon/Pleroma. Keep unsupported controls out of the UI instead of sending
|
||||
* fields the server silently ignores and claiming that they were saved.
|
||||
*/
|
||||
export function publicProfileCapabilities(
|
||||
instance: InstanceInfo | null | undefined,
|
||||
): PublicProfileCapabilities {
|
||||
if (isEgregoros(instance)) {
|
||||
return {
|
||||
avatar: true,
|
||||
removeAvatar: false,
|
||||
header: false,
|
||||
fields: false,
|
||||
identity: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avatar: true,
|
||||
removeAvatar: true,
|
||||
header: true,
|
||||
fields: true,
|
||||
identity: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiClient } from './client'
|
||||
|
||||
function response(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ApiClient Egregoros compatibility', () => {
|
||||
it('refreshes an expired bearer token once and retries the request', async () => {
|
||||
const refreshToken = vi.fn().mockResolvedValue('fresh-token')
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(response({ error: 'Invalid or expired token' }, 401))
|
||||
.mockResolvedValueOnce(response({ id: 'alice' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const client = new ApiClient('egregoros.example', 'expired-token', refreshToken)
|
||||
|
||||
await expect(client.get('/api/v1/accounts/verify_credentials')).resolves.toEqual({
|
||||
id: 'alice',
|
||||
})
|
||||
|
||||
expect(refreshToken).toHaveBeenCalledOnce()
|
||||
expect((fetchMock.mock.calls[0][1].headers as Headers).get('Authorization')).toBe(
|
||||
'Bearer expired-token',
|
||||
)
|
||||
expect((fetchMock.mock.calls[1][1].headers as Headers).get('Authorization')).toBe(
|
||||
'Bearer fresh-token',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not invent a next page when the server supplied only a prev link', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
response(
|
||||
[{ id: 'newest' }, { id: 'oldest' }],
|
||||
200,
|
||||
{
|
||||
Link: '<https://egregoros.example/api/v1/timelines/public?since_id=newest>; rel="prev"',
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const page = await new ApiClient('egregoros.example').page<{ id: string }>(
|
||||
'/api/v1/timelines/public',
|
||||
{ limit: 20 },
|
||||
)
|
||||
|
||||
expect(page.links.sinceId).toBe('newest')
|
||||
expect(page.links.maxId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives a next cursor only for a full page when Link was stripped entirely', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(response([{ id: 'newest' }, { id: 'oldest' }])),
|
||||
)
|
||||
|
||||
const page = await new ApiClient('egregoros.example').page<{ id: string }>(
|
||||
'/api/v1/timelines/public',
|
||||
{ limit: 2 },
|
||||
)
|
||||
|
||||
expect(page.links.maxId).toBe('oldest')
|
||||
})
|
||||
})
|
||||
+49
-5
@@ -39,10 +39,13 @@ export interface PageLinks {
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
links: PageLinks
|
||||
/** Optional deletion/tombstone IDs supplied by streaming-aware loaders. */
|
||||
deletedIds?: string[]
|
||||
}
|
||||
|
||||
export type QueryValue = string | number | boolean | undefined | null | string[]
|
||||
export type Query = Record<string, QueryValue>
|
||||
export type AccessTokenRefresher = () => Promise<string | null>
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
@@ -122,10 +125,16 @@ function buildQuery(query: Query | undefined): string {
|
||||
export class ApiClient {
|
||||
readonly host: string
|
||||
private token: string | null
|
||||
private readonly refreshAccessToken?: AccessTokenRefresher
|
||||
|
||||
constructor(host: string, token: string | null = null) {
|
||||
constructor(
|
||||
host: string,
|
||||
token: string | null = null,
|
||||
refreshAccessToken?: AccessTokenRefresher,
|
||||
) {
|
||||
this.host = normalizeHost(host)
|
||||
this.token = token
|
||||
this.refreshAccessToken = refreshAccessToken
|
||||
}
|
||||
|
||||
get origin(): string {
|
||||
@@ -141,7 +150,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
withToken(token: string | null): ApiClient {
|
||||
return new ApiClient(this.host, token)
|
||||
return new ApiClient(this.host, token, this.refreshAccessToken)
|
||||
}
|
||||
|
||||
private url(path: string, query?: Query): string {
|
||||
@@ -162,6 +171,14 @@ export class ApiClient {
|
||||
|
||||
/** Perform a request and return the parsed body plus the raw response. */
|
||||
async raw<T>(path: string, options: RequestOptions = {}): Promise<{ data: T; response: Response }> {
|
||||
return this.perform<T>(path, options, true)
|
||||
}
|
||||
|
||||
private async perform<T>(
|
||||
path: string,
|
||||
options: RequestOptions,
|
||||
mayRefresh: boolean,
|
||||
): Promise<{ data: T; response: Response }> {
|
||||
const url = this.url(path, options.query)
|
||||
let response: Response
|
||||
try {
|
||||
@@ -185,6 +202,26 @@ export class ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
mayRefresh &&
|
||||
options.token === undefined &&
|
||||
this.token &&
|
||||
this.refreshAccessToken
|
||||
) {
|
||||
let refreshed: string | null = null
|
||||
try {
|
||||
refreshed = await this.refreshAccessToken()
|
||||
} catch {
|
||||
// Preserve the original API response when renewal itself fails. The
|
||||
// session owns clearing invalid refresh credentials.
|
||||
}
|
||||
if (refreshed) {
|
||||
this.token = refreshed
|
||||
return this.perform<T>(path, options, false)
|
||||
}
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
@@ -249,11 +286,18 @@ export class ApiClient {
|
||||
async page<T>(path: string, query?: Query, options: RequestOptions = {}): Promise<Page<T>> {
|
||||
const { data, response } = await this.raw<T[]>(path, { ...options, method: 'GET', query })
|
||||
const items = Array.isArray(data) ? data : []
|
||||
const links = parseLinkHeader(response.headers.get('Link'))
|
||||
const linkHeader = response.headers.get('Link')
|
||||
const links = parseLinkHeader(linkHeader)
|
||||
|
||||
// Fallback for servers that drop the Link header: derive `max_id` from the
|
||||
// last item so "see more" keeps working.
|
||||
if (!links.maxId && items.length > 0) {
|
||||
// last item of a full page so "see more" keeps working. Do not fabricate a
|
||||
// next cursor when the server explicitly supplied only `rel="prev"`:
|
||||
// Egregoros does that on its final page.
|
||||
const requestedLimit =
|
||||
typeof query?.limit === 'number' && Number.isFinite(query.limit)
|
||||
? Math.max(1, query.limit)
|
||||
: 20
|
||||
if (!linkHeader && !links.maxId && items.length >= requestedLimit) {
|
||||
const last = items[items.length - 1] as { id?: string }
|
||||
if (last && typeof last.id === 'string') links.maxId = last.id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiClient } from './client'
|
||||
import {
|
||||
fetchNotifications,
|
||||
postStatus,
|
||||
updateProfileFields,
|
||||
updatePublicProfile,
|
||||
votePoll,
|
||||
} from './endpoints'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('updateProfileFields', () => {
|
||||
it('uses the Pleroma/Mastodon indexed multipart representation', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.method).toBe('PATCH')
|
||||
expect(init?.headers).toBeInstanceOf(Headers)
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer token')
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const form = init?.body as FormData
|
||||
expect([...form.entries()]).toEqual([
|
||||
['fields_attributes[0][name]', 'Website'],
|
||||
['fields_attributes[0][value]', 'https://example.test'],
|
||||
['fields_attributes[1][name]', 'plspace:css1'],
|
||||
['fields_attributes[1][value]', 'body { color: pink; }'],
|
||||
])
|
||||
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await updateProfileFields(new ApiClient('example.test', 'token'), [
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'plspace:css1', value: 'body { color: pink; }' },
|
||||
])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/api/v1/accounts/update_credentials',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePublicProfile', () => {
|
||||
it('sends public text, images, fields and Pleroma identity data as multipart form data', async () => {
|
||||
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' })
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const form = init?.body as FormData
|
||||
expect(init?.method).toBe('PATCH')
|
||||
expect(form.get('display_name')).toBe('Alice Example')
|
||||
expect(form.get('note')).toBe('My public bio')
|
||||
expect(form.get('avatar')).toBe(avatar)
|
||||
expect(form.get('header')).toBe('')
|
||||
expect(form.get('actor_type')).toBe('Group')
|
||||
expect(form.get('birthday')).toBe('2000-01-02')
|
||||
expect(form.get('show_birthday')).toBe('true')
|
||||
expect(form.get('fields_attributes[0][name]')).toBe('Website')
|
||||
expect(form.get('fields_attributes[0][value]')).toBe('https://example.test')
|
||||
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await updatePublicProfile(new ApiClient('example.test', 'token'), {
|
||||
displayName: 'Alice Example',
|
||||
note: 'My public bio',
|
||||
avatar,
|
||||
header: '',
|
||||
actorType: 'Group',
|
||||
birthday: '2000-01-02',
|
||||
showBirthday: true,
|
||||
fields: [{ name: 'Website', value: 'https://example.test' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends an empty sentinel row when all public fields are cleared', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const form = init?.body as FormData
|
||||
expect(form.get('fields_attributes[0][name]')).toBe('')
|
||||
expect(form.get('fields_attributes[0][value]')).toBe('')
|
||||
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await updatePublicProfile(new ApiClient('example.test', 'token'), { fields: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('poll endpoints', () => {
|
||||
it('submits selected poll option indexes as JSON', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ choices: [0, 2] })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'poll-1',
|
||||
expired: false,
|
||||
multiple: true,
|
||||
votes_count: 2,
|
||||
options: [],
|
||||
emojis: [],
|
||||
voted: true,
|
||||
own_votes: [0, 2],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await votePoll(new ApiClient('example.test', 'token'), 'poll/one', [0, 2])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/api/v1/polls/poll%2Fone/votes',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('includes a standard Mastodon/Pleroma poll in a new status', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
status: 'Pick a snack',
|
||||
poll: {
|
||||
options: ['Cake', 'Fruit'],
|
||||
expires_in: 3600,
|
||||
multiple: false,
|
||||
},
|
||||
})
|
||||
return new Response(JSON.stringify({ id: 'status-1' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await postStatus(new ApiClient('example.test', 'token'), {
|
||||
status: 'Pick a snack',
|
||||
poll: {
|
||||
options: ['Cake', 'Fruit'],
|
||||
expires_in: 3600,
|
||||
multiple: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNotifications', () => {
|
||||
it('defensively applies requested types when a server ignores the filter', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{ id: 'favourite-1', type: 'favourite' },
|
||||
{ id: 'mention-1', type: 'mention' },
|
||||
]),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const page = await fetchNotifications(
|
||||
new ApiClient('egregoros.example', 'token'),
|
||||
{ limit: 20 },
|
||||
['favourite'],
|
||||
)
|
||||
|
||||
expect(page.items).toEqual([{ id: 'favourite-1', type: 'favourite' }])
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
InstanceInfo,
|
||||
MediaAttachment,
|
||||
Notification,
|
||||
Poll,
|
||||
Relationship,
|
||||
SearchResults,
|
||||
Status,
|
||||
@@ -85,6 +86,75 @@ export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
|
||||
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
|
||||
}
|
||||
|
||||
export interface PublicProfileUpdate {
|
||||
displayName?: string
|
||||
note?: string
|
||||
fields?: Array<{ name: string; value: string }>
|
||||
avatar?: File | '' | undefined
|
||||
header?: File | '' | undefined
|
||||
background?: File | '' | undefined
|
||||
avatarDescription?: string
|
||||
headerDescription?: string
|
||||
bot?: boolean
|
||||
actorType?: 'Person' | 'Service' | 'Group'
|
||||
birthday?: string
|
||||
showBirthday?: boolean
|
||||
}
|
||||
|
||||
/** Update only public-facing account profile data through the shared API. */
|
||||
export async function updatePublicProfile(
|
||||
api: ApiClient,
|
||||
update: PublicProfileUpdate,
|
||||
): Promise<CredentialAccount> {
|
||||
const form = new FormData()
|
||||
if (update.displayName !== undefined) form.set('display_name', update.displayName)
|
||||
if (update.note !== undefined) form.set('note', update.note)
|
||||
if (update.avatar !== undefined) form.set('avatar', update.avatar)
|
||||
if (update.header !== undefined) form.set('header', update.header)
|
||||
if (update.background !== undefined) form.set('pleroma_background_image', update.background)
|
||||
if (update.avatarDescription !== undefined) {
|
||||
form.set('avatar_description', update.avatarDescription)
|
||||
}
|
||||
if (update.headerDescription !== undefined) {
|
||||
form.set('header_description', update.headerDescription)
|
||||
}
|
||||
if (update.bot !== undefined) form.set('bot', String(update.bot))
|
||||
if (update.actorType !== undefined) form.set('actor_type', update.actorType)
|
||||
if (update.birthday !== undefined) form.set('birthday', update.birthday)
|
||||
if (update.showBirthday !== undefined) form.set('show_birthday', String(update.showBirthday))
|
||||
// An omitted collection means "leave fields unchanged". To explicitly clear
|
||||
// every field, submit one empty row; Pleroma and Mastodon both discard it
|
||||
// while still recognizing that fields_attributes was present.
|
||||
const submittedFields =
|
||||
update.fields === undefined
|
||||
? undefined
|
||||
: update.fields.length > 0
|
||||
? update.fields
|
||||
: [{ name: '', value: '' }]
|
||||
submittedFields?.forEach((field, index) => {
|
||||
form.append(`fields_attributes[${index}][name]`, field.name)
|
||||
form.append(`fields_attributes[${index}][value]`, field.value)
|
||||
})
|
||||
|
||||
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
|
||||
method: 'PATCH',
|
||||
form,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the signed-in account's profile fields while leaving every other
|
||||
* credential untouched. Mastodon and Pleroma both accept this indexed
|
||||
* multipart shape; Pleroma-FE uses the same representation.
|
||||
*/
|
||||
export async function updateProfileFields(
|
||||
api: ApiClient,
|
||||
fields: Array<{ name: string; value: string }>,
|
||||
): Promise<CredentialAccount> {
|
||||
return updatePublicProfile(api, { fields })
|
||||
}
|
||||
|
||||
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
|
||||
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
|
||||
}
|
||||
@@ -208,6 +278,11 @@ export interface ComposeOptions {
|
||||
sensitive?: boolean
|
||||
media_ids?: string[]
|
||||
language?: string
|
||||
poll?: {
|
||||
options: string[]
|
||||
expires_in: number
|
||||
multiple: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Status> {
|
||||
@@ -221,6 +296,7 @@ export function postStatus(api: ApiClient, options: ComposeOptions): Promise<Sta
|
||||
if (options.sensitive) body.sensitive = true
|
||||
if (options.media_ids?.length) body.media_ids = options.media_ids
|
||||
if (options.language) body.language = options.language
|
||||
if (options.poll) body.poll = options.poll
|
||||
return api.post<Status>('/api/v1/statuses', body)
|
||||
}
|
||||
|
||||
@@ -238,6 +314,10 @@ export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise<S
|
||||
return api.post<Status>(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`)
|
||||
}
|
||||
|
||||
export function votePoll(api: ApiClient, id: string, choices: number[]): Promise<Poll> {
|
||||
return api.post<Poll>(`/api/v1/polls/${encodeURIComponent(id)}/votes`, { choices })
|
||||
}
|
||||
|
||||
export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise<MediaAttachment> {
|
||||
const form = new FormData()
|
||||
form.set('file', file)
|
||||
@@ -250,14 +330,21 @@ export async function uploadMedia(api: ApiClient, file: File, description?: stri
|
||||
|
||||
/* ------------------------------------------------------------- mail centre */
|
||||
|
||||
export function fetchNotifications(
|
||||
export async function fetchNotifications(
|
||||
api: ApiClient,
|
||||
cursor: Cursor = {},
|
||||
types?: string[],
|
||||
): Promise<Page<Notification>> {
|
||||
const query: Query = { ...cursor }
|
||||
if (types?.length) query.types = types
|
||||
return api.page<Notification>('/api/v1/notifications', query)
|
||||
const page = await api.page<Notification>('/api/v1/notifications', query)
|
||||
if (!types?.length) return page
|
||||
|
||||
// Egregoros currently accepts but ignores `types[]`. Apply the same filter
|
||||
// locally so Mail folders remain correct; this is harmless when the server
|
||||
// already filtered the page.
|
||||
const allowed = new Set(types)
|
||||
return { ...page, items: page.items.filter((notification) => allowed.has(notification.type)) }
|
||||
}
|
||||
|
||||
export function fetchFollowRequests(api: ApiClient, cursor: Cursor = {}): Promise<Page<Account>> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
completeLogin,
|
||||
ensureApp,
|
||||
pkceChallenge,
|
||||
refreshAccessToken,
|
||||
redirectUri,
|
||||
revoke,
|
||||
SCOPES,
|
||||
@@ -176,6 +177,59 @@ describe('OAuth request compatibility', () => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains Egregoros refresh credentials and rotates them with form encoding', async () => {
|
||||
saveApp()
|
||||
savePending()
|
||||
window.history.replaceState({}, '', '/?code=authorization-code&state=expected-state')
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
access_token: 'first-access-token',
|
||||
refresh_token: 'first-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
scope: SCOPES,
|
||||
created_at: 123,
|
||||
expires_in: 3600,
|
||||
authorization_expires_in: 31_536_000,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
access_token: 'rotated-access-token',
|
||||
refresh_token: 'rotated-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
scope: SCOPES,
|
||||
created_at: 456,
|
||||
expires_in: 3600,
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(completeLogin()).resolves.toEqual({
|
||||
host: 'social.example',
|
||||
token: 'first-access-token',
|
||||
refreshToken: 'first-refresh-token',
|
||||
returnTo: '#/timeline/home',
|
||||
})
|
||||
await expect(
|
||||
refreshAccessToken('social.example', 'first-refresh-token'),
|
||||
).resolves.toEqual({
|
||||
token: 'rotated-access-token',
|
||||
refreshToken: 'rotated-refresh-token',
|
||||
})
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[1] as [string, RequestInit]
|
||||
expect(url).toBe('https://social.example/oauth/token')
|
||||
expect(options.body).toBeInstanceOf(URLSearchParams)
|
||||
expect(Object.fromEntries(new URLSearchParams(String(options.body)))).toEqual({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
refresh_token: 'first-refresh-token',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects expired attempts before exchanging the code', async () => {
|
||||
saveApp()
|
||||
savePending({ createdAt: Date.now() - 11 * 60 * 1000 })
|
||||
|
||||
+47
-1
@@ -157,9 +157,15 @@ export async function beginLogin(
|
||||
export interface CompletedLogin {
|
||||
host: string
|
||||
token: string
|
||||
refreshToken?: string
|
||||
returnTo: string
|
||||
}
|
||||
|
||||
export interface RefreshedAccessToken {
|
||||
token: string
|
||||
refreshToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the flow if the current URL carries an authorization code.
|
||||
* Returns null when this is an ordinary page load.
|
||||
@@ -211,7 +217,47 @@ export async function completeLogin(): Promise<CompletedLogin | null> {
|
||||
throw new Error('The server returned an invalid OAuth token response.')
|
||||
}
|
||||
|
||||
return { host: pending.host, token: token.access_token, returnTo: pending.returnTo || '#/' }
|
||||
return {
|
||||
host: pending.host,
|
||||
token: token.access_token,
|
||||
...(validRefreshToken(token.refresh_token) ? { refreshToken: token.refresh_token } : {}),
|
||||
returnTo: pending.returnTo || '#/',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew a short-lived access token. Egregoros rotates its refresh token on
|
||||
* every use, while older Mastodon/Pleroma servers simply omit refresh tokens;
|
||||
* keeping this optional preserves both behaviours.
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
host: string,
|
||||
refreshToken: string,
|
||||
): Promise<RefreshedAccessToken> {
|
||||
const key = normalizeHost(host)
|
||||
const app = readApps()[key]
|
||||
if (!app?.client_id || !app.client_secret) {
|
||||
throw new Error('Lost the app registration for this server. Please sign in again.')
|
||||
}
|
||||
|
||||
const token = await new ApiClient(key).postForm<OAuthToken>('/oauth/token', {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
if (!token || typeof token.access_token !== 'string' || !token.access_token) {
|
||||
throw new Error('The server returned an invalid OAuth token response.')
|
||||
}
|
||||
|
||||
return {
|
||||
token: token.access_token,
|
||||
...(validRefreshToken(token.refresh_token) ? { refreshToken: token.refresh_token } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function validRefreshToken(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function parsePending(raw: string): PendingAuth {
|
||||
|
||||
+40
-2
@@ -47,8 +47,10 @@ export interface Account {
|
||||
uri?: string
|
||||
avatar: string
|
||||
avatar_static: string
|
||||
avatar_description?: string | null
|
||||
header: string
|
||||
header_static: string
|
||||
header_description?: string | null
|
||||
locked: boolean
|
||||
bot?: boolean
|
||||
group?: boolean
|
||||
@@ -84,6 +86,9 @@ export interface Account {
|
||||
relationship?: Relationship
|
||||
/** Some deployments expose the user's own profile CSS here. */
|
||||
background_color?: string | null
|
||||
birthday?: string | null
|
||||
avatar_description?: string | null
|
||||
header_description?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +100,11 @@ export interface CredentialAccount extends Account {
|
||||
sensitive: boolean
|
||||
language: string | null
|
||||
follow_requests_count?: number
|
||||
pleroma?: {
|
||||
actor_type?: 'Person' | 'Service' | 'Group'
|
||||
show_birthday?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +276,36 @@ export interface InstanceInfo {
|
||||
max_characters?: number
|
||||
max_media_attachments?: number
|
||||
}
|
||||
polls?: {
|
||||
max_options?: number
|
||||
max_characters_per_option?: number
|
||||
min_expiration?: number
|
||||
max_expiration?: number
|
||||
}
|
||||
accounts?: {
|
||||
max_profile_fields?: number
|
||||
/** Pleroma v2 names. */
|
||||
profile_field_name_limit?: number
|
||||
profile_field_value_limit?: number
|
||||
/** Mastodon-compatible aliases used by some implementations. */
|
||||
max_profile_field_name_length?: number
|
||||
max_profile_field_value_length?: number
|
||||
}
|
||||
}
|
||||
registrations?: boolean | { enabled?: boolean }
|
||||
contact_account?: Account | null
|
||||
/** Pleroma reports its "real" upstream here. */
|
||||
pleroma?: unknown
|
||||
/** Pleroma reports its "real" upstream and field limits here. */
|
||||
pleroma?: {
|
||||
metadata?: {
|
||||
fields_limits?: {
|
||||
max_fields?: number
|
||||
name_length?: number
|
||||
value_length?: number
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
@@ -294,7 +329,10 @@ export interface OAuthApp {
|
||||
|
||||
export interface OAuthToken {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
scope: string
|
||||
created_at: number
|
||||
expires_in?: number
|
||||
authorization_expires_in?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { account, status } from '$test/fixtures'
|
||||
import type { Notification } from './api/types'
|
||||
import { NotificationTracker, presentNotification } from './notifications'
|
||||
|
||||
function notification(overrides: Partial<Notification> = {}): Notification {
|
||||
return {
|
||||
id: 'notification-1',
|
||||
type: 'mention',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
account: account({ display_name: 'Bob', acct: 'bob' }),
|
||||
status: status({ id: 'mentioned-entry', content: '<p>Hello there</p>' }),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NotificationTracker', () => {
|
||||
it('uses the first page as a quiet baseline and emits only unseen items later', () => {
|
||||
const tracker = new NotificationTracker()
|
||||
const baseline = notification({ id: 'old' })
|
||||
const fresh = notification({ id: 'new' })
|
||||
|
||||
expect(tracker.ingest([baseline])).toEqual([])
|
||||
expect(tracker.cursor).toBe('old')
|
||||
expect(tracker.ingest([fresh, baseline])).toEqual([fresh])
|
||||
expect(tracker.cursor).toBe('new')
|
||||
expect(tracker.ingest([fresh, baseline])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentNotification', () => {
|
||||
it('links status notifications to the relevant entry', () => {
|
||||
expect(presentNotification(notification())).toEqual({
|
||||
actor: 'Bob',
|
||||
message: 'mentioned you in an entry',
|
||||
excerpt: 'Hello there',
|
||||
href: '#/blog/mentioned-entry',
|
||||
})
|
||||
})
|
||||
|
||||
it('links friend requests to their manager', () => {
|
||||
expect(
|
||||
presentNotification(notification({ type: 'follow_request', status: null })).href,
|
||||
).toBe('#/mail/requests')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Notification } from './api/types'
|
||||
import { toPlainText } from './util/html'
|
||||
import { displayNameOf, profilePath } from './util/profile'
|
||||
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 45_000
|
||||
export const NOTIFICATION_DISMISS_AFTER_MS = 7_000
|
||||
|
||||
/** Keeps polling incremental and prevents overlapping server pages from
|
||||
* producing the same toast twice. The first page is baseline only. */
|
||||
export class NotificationTracker {
|
||||
private initialized = false
|
||||
private known = new Set<string>()
|
||||
private knownOrder: string[] = []
|
||||
|
||||
cursor: string | undefined
|
||||
|
||||
reset(): void {
|
||||
this.initialized = false
|
||||
this.known = new Set()
|
||||
this.knownOrder = []
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
ingest(items: Notification[]): Notification[] {
|
||||
const fresh = this.initialized
|
||||
? items.filter((notification) => !this.known.has(notification.id))
|
||||
: []
|
||||
|
||||
for (const notification of items) this.remember(notification.id)
|
||||
// Both Mastodon and Pleroma return notification pages newest-first.
|
||||
if (items[0]) this.cursor = items[0].id
|
||||
this.initialized = true
|
||||
return fresh
|
||||
}
|
||||
|
||||
private remember(id: string): void {
|
||||
if (this.known.has(id)) return
|
||||
this.known.add(id)
|
||||
this.knownOrder.push(id)
|
||||
|
||||
// A long-running tab should not retain an unbounded notification history.
|
||||
while (this.knownOrder.length > 200) {
|
||||
const oldest = this.knownOrder.shift()
|
||||
if (oldest) this.known.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationPresentation {
|
||||
actor: string
|
||||
message: string
|
||||
excerpt: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const MESSAGE: Record<string, string> = {
|
||||
mention: 'mentioned you in an entry',
|
||||
status: 'posted a new entry',
|
||||
reblog: 'reposted your entry',
|
||||
follow: 'added you as a friend',
|
||||
follow_request: 'sent you a friend request',
|
||||
favourite: 'gave your entry kudos',
|
||||
poll: 'has a poll that just ended',
|
||||
update: 'edited an entry',
|
||||
'pleroma:emoji_reaction': 'reacted to your entry',
|
||||
'admin.sign_up': 'joined the server',
|
||||
'admin.report': 'was included in a report',
|
||||
'pleroma:report': 'was included in a report',
|
||||
}
|
||||
|
||||
export function presentNotification(notification: Notification): NotificationPresentation {
|
||||
const status = notification.status ?? null
|
||||
const excerpt = status
|
||||
? toPlainText(status.spoiler_text || status.content).slice(0, 100)
|
||||
: ''
|
||||
|
||||
const href =
|
||||
notification.type === 'follow_request'
|
||||
? '#/mail/requests'
|
||||
: status
|
||||
? `#/blog/${status.id}`
|
||||
: profilePath(notification.account)
|
||||
|
||||
return {
|
||||
actor: displayNameOf(notification.account),
|
||||
message: MESSAGE[notification.type] ?? 'sent you a notification',
|
||||
excerpt,
|
||||
href,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,123 @@ export interface Identified {
|
||||
|
||||
export type Loader<T> = (cursor: Cursor) => Promise<Page<T>>
|
||||
|
||||
interface RefreshOptions {
|
||||
/** A linked next page means an empty filtered page is not authoritative. */
|
||||
emptyIsAuthoritative?: boolean
|
||||
/** Oldest timestamp the server query proved it had covered. */
|
||||
coveredThroughTimestamp?: number
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function deletionId(value: unknown): string | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) return null
|
||||
if (record.event === 'delete' && typeof record.payload === 'string') {
|
||||
return record.payload
|
||||
}
|
||||
if (
|
||||
(record.deleted === true || record.tombstone === true) &&
|
||||
typeof record.id === 'string'
|
||||
) {
|
||||
return record.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nestedReblog(value: unknown): Identified | null {
|
||||
const record = asRecord(value)
|
||||
const reblog = asRecord(record?.reblog)
|
||||
return reblog && typeof reblog.id === 'string'
|
||||
? (reblog as unknown as Identified)
|
||||
: null
|
||||
}
|
||||
|
||||
function createdAt(value: unknown): number | null {
|
||||
const record = asRecord(value)
|
||||
if (typeof record?.created_at !== 'string') return null
|
||||
const timestamp = Date.parse(record.created_at)
|
||||
return Number.isNaN(timestamp) ? null : timestamp
|
||||
}
|
||||
|
||||
function referencesId(value: Identified, ids: Set<string>): boolean {
|
||||
const reblog = nestedReblog(value)
|
||||
return ids.has(value.id) || Boolean(reblog && ids.has(reblog.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a newest-first timeline response without replacing the existing list.
|
||||
*
|
||||
* Matching entries receive fresh API metadata, unseen entries are prepended,
|
||||
* explicit deletions remove direct posts and repost wrappers, and an absent
|
||||
* entry inside the returned chronological window is treated as removed. Older
|
||||
* paginated entries outside that window remain untouched.
|
||||
*/
|
||||
export function reconcileRefreshItems<T extends Identified>(
|
||||
current: T[],
|
||||
responseItems: T[],
|
||||
deletedIds: readonly string[] = [],
|
||||
options: RefreshOptions = {},
|
||||
): T[] {
|
||||
const removed = new Set(deletedIds)
|
||||
const incoming: T[] = []
|
||||
|
||||
for (const item of responseItems) {
|
||||
const deletedId = deletionId(item)
|
||||
if (deletedId) {
|
||||
removed.add(deletedId)
|
||||
} else if (item && typeof item.id === 'string') {
|
||||
incoming.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
if (incoming.length === 0) {
|
||||
if (options.emptyIsAuthoritative) return []
|
||||
return current.filter((item) => !referencesId(item, removed))
|
||||
}
|
||||
|
||||
const incomingTopIds = new Set(incoming.map((item) => item.id))
|
||||
const incomingById = new Map<string, Identified>()
|
||||
for (const item of incoming) {
|
||||
const reblog = nestedReblog(item)
|
||||
if (reblog) incomingById.set(reblog.id, reblog)
|
||||
incomingById.set(item.id, item)
|
||||
}
|
||||
|
||||
const timestamps = incoming
|
||||
.map(createdAt)
|
||||
.filter((timestamp): timestamp is number => timestamp !== null)
|
||||
const oldestIncoming =
|
||||
options.coveredThroughTimestamp ??
|
||||
(timestamps.length > 0 ? Math.min(...timestamps) : null)
|
||||
const retainedOlder = current
|
||||
.filter((item) => {
|
||||
if (referencesId(item, removed)) return false
|
||||
if (incomingTopIds.has(item.id)) return false
|
||||
const timestamp = createdAt(item)
|
||||
// A missing entry newer than the oldest returned entry would have been
|
||||
// present in this response if it still belonged to the timeline.
|
||||
return oldestIncoming === null || timestamp === null || timestamp <= oldestIncoming
|
||||
})
|
||||
.map((item) => {
|
||||
const direct = incomingById.get(item.id)
|
||||
if (direct) return direct as T
|
||||
|
||||
const reblog = nestedReblog(item)
|
||||
const updatedReblog = reblog ? incomingById.get(reblog.id) : null
|
||||
return updatedReblog
|
||||
? ({ ...(item as object), reblog: updatedReblog } as unknown as T)
|
||||
: item
|
||||
})
|
||||
|
||||
const refreshedWindow = incoming.filter((item) => !referencesId(item, removed))
|
||||
return [...refreshedWindow, ...retainedOlder]
|
||||
}
|
||||
|
||||
export class Feed<T extends Identified> {
|
||||
items = $state<T[]>([])
|
||||
loading = $state(false)
|
||||
@@ -57,6 +174,109 @@ export class Feed<T extends Identified> {
|
||||
await this.run(this.generation, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the visible window in place. Unlike `reload`, this never clears the
|
||||
* list or resets its older-page cursor.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
if (this.loading) return
|
||||
const generation = ++this.generation
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const oldestVisible = this.items.at(-1)
|
||||
const oldestVisibleAt = createdAt(oldestVisible)
|
||||
const collected: T[] = []
|
||||
const collectedIds = new Set<string>()
|
||||
const deletedIds: string[] = []
|
||||
let cursor: string | undefined
|
||||
let coveredThroughTimestamp: number | undefined
|
||||
let emptyIsAuthoritative = false
|
||||
const maxPages = Math.min(
|
||||
20,
|
||||
Math.max(2, Math.ceil(this.items.length / 40) + 2),
|
||||
)
|
||||
|
||||
for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
|
||||
const page = await this.loader({ max_id: cursor, limit: 40 })
|
||||
if (generation !== this.generation) return
|
||||
deletedIds.push(...(page.deletedIds ?? []))
|
||||
|
||||
const livePage = page.items.filter((item) => !deletionId(item))
|
||||
for (const item of page.items) {
|
||||
const removedId = deletionId(item)
|
||||
if (removedId) {
|
||||
deletedIds.push(removedId)
|
||||
} else if (!collectedIds.has(item.id)) {
|
||||
collectedIds.add(item.id)
|
||||
collected.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
if (!oldestVisible) break
|
||||
|
||||
const boundaryIndex = collected.findIndex(
|
||||
(item) => item.id === oldestVisible.id,
|
||||
)
|
||||
if (boundaryIndex >= 0) {
|
||||
collected.splice(boundaryIndex + 1)
|
||||
coveredThroughTimestamp = oldestVisibleAt ?? undefined
|
||||
break
|
||||
}
|
||||
|
||||
if (oldestVisibleAt !== null) {
|
||||
const timestampsBelowBoundary = collected
|
||||
.map(createdAt)
|
||||
.filter(
|
||||
(timestamp): timestamp is number =>
|
||||
timestamp !== null && timestamp < oldestVisibleAt,
|
||||
)
|
||||
if (timestampsBelowBoundary.length > 0) {
|
||||
coveredThroughTimestamp = Math.min(...timestampsBelowBoundary)
|
||||
const withinVisibleWindow = collected.filter((item) => {
|
||||
const timestamp = createdAt(item)
|
||||
return timestamp === null || timestamp >= oldestVisibleAt
|
||||
})
|
||||
collected.splice(0, collected.length, ...withinVisibleWindow)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (page.items.length === 0) {
|
||||
emptyIsAuthoritative = true
|
||||
coveredThroughTimestamp = Number.NEGATIVE_INFINITY
|
||||
break
|
||||
}
|
||||
|
||||
const nextCursor = page.links.maxId ?? livePage.at(-1)?.id
|
||||
if (!nextCursor || nextCursor === cursor) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
this.items = reconcileRefreshItems(
|
||||
this.items,
|
||||
collected,
|
||||
deletedIds,
|
||||
{ emptyIsAuthoritative, coveredThroughTimestamp },
|
||||
)
|
||||
this.seen = new Set(this.items.map((item) => item.id))
|
||||
} catch (cause) {
|
||||
if (generation !== this.generation) return
|
||||
this.error =
|
||||
cause instanceof ApiError
|
||||
? cause.message
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: 'Something went wrong.'
|
||||
} finally {
|
||||
if (generation === this.generation) {
|
||||
this.loading = false
|
||||
this.initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async run(generation: number, replace: boolean): Promise<void> {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Page } from '../api/client'
|
||||
import type { Cursor } from '../api/endpoints'
|
||||
import { Feed, reconcileRefreshItems } from './feed.svelte'
|
||||
|
||||
interface Entry {
|
||||
id: string
|
||||
created_at: string
|
||||
favourites_count: number
|
||||
reblogs_count?: number
|
||||
reblog?: Entry | null
|
||||
}
|
||||
|
||||
function entry(
|
||||
id: string,
|
||||
minute: number,
|
||||
favourites_count = 0,
|
||||
reblog: Entry | null = null,
|
||||
): Entry {
|
||||
return {
|
||||
id,
|
||||
created_at: `2026-01-01T00:${String(minute).padStart(2, '0')}:00.000Z`,
|
||||
favourites_count,
|
||||
reblog,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Feed.refresh', () => {
|
||||
it('prepends new entries, updates metadata, and preserves older pagination', async () => {
|
||||
const newest = entry('new', 12)
|
||||
const updated = { ...entry('first', 11, 7), reblogs_count: 4 }
|
||||
const first = entry('first', 11)
|
||||
const older = entry('older', 10)
|
||||
const oldest = entry('oldest', 9)
|
||||
const loader = vi.fn(async (cursor: Cursor): Promise<Page<Entry>> => {
|
||||
if (cursor.max_id === 'older') {
|
||||
return { items: [oldest], links: {} }
|
||||
}
|
||||
if (cursor.limit === 20 && loader.mock.calls.length === 1) {
|
||||
return { items: [first, older], links: { maxId: 'older' } }
|
||||
}
|
||||
return { items: [newest, updated, older], links: { maxId: 'older' } }
|
||||
})
|
||||
const feed = new Feed(loader, 20)
|
||||
|
||||
await feed.reload()
|
||||
await feed.refresh()
|
||||
|
||||
expect(feed.items).toEqual([newest, updated, older])
|
||||
expect(feed.items.find((item) => item.id === 'first')?.favourites_count).toBe(7)
|
||||
expect(feed.items.find((item) => item.id === 'first')?.reblogs_count).toBe(4)
|
||||
|
||||
await feed.loadMore()
|
||||
expect(loader.mock.calls[2][0]).toMatchObject({ max_id: 'older' })
|
||||
expect(feed.items).toEqual([newest, updated, older, oldest])
|
||||
})
|
||||
|
||||
it('keeps the visible list in place while a refresh request is pending', async () => {
|
||||
let resolveRefresh!: (page: Page<Entry>) => void
|
||||
const refreshPage = new Promise<Page<Entry>>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
const visible = entry('visible', 10)
|
||||
const loader = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: [visible], links: {} })
|
||||
.mockReturnValueOnce(refreshPage)
|
||||
const feed = new Feed<Entry>(loader)
|
||||
await feed.reload()
|
||||
|
||||
const pending = feed.refresh()
|
||||
expect(feed.items).toEqual([visible])
|
||||
|
||||
resolveRefresh({ items: [entry('visible', 10, 3)], links: {} })
|
||||
await pending
|
||||
expect(feed.items[0].favourites_count).toBe(3)
|
||||
})
|
||||
|
||||
it('queries enough newest pages to update every loaded visible entry', async () => {
|
||||
const first = entry('first', 12)
|
||||
const second = entry('second', 11)
|
||||
const third = entry('third', 10)
|
||||
const loader = vi.fn(async (cursor: Cursor): Promise<Page<Entry>> => {
|
||||
if (cursor.limit === 2 && !cursor.max_id) {
|
||||
return { items: [first, second], links: { maxId: 'second' } }
|
||||
}
|
||||
if (cursor.limit === 2 && cursor.max_id === 'second') {
|
||||
return { items: [third], links: {} }
|
||||
}
|
||||
if (!cursor.max_id) {
|
||||
return {
|
||||
items: [entry('new', 13), { ...first, reblogs_count: 2 }],
|
||||
links: { maxId: 'first' },
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: [{ ...second, favourites_count: 6 }, third],
|
||||
links: { maxId: 'third' },
|
||||
}
|
||||
})
|
||||
const feed = new Feed(loader, 2)
|
||||
await feed.reload()
|
||||
await feed.loadMore()
|
||||
|
||||
await feed.refresh()
|
||||
|
||||
expect(feed.items.map((item) => item.id)).toEqual([
|
||||
'new',
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
])
|
||||
expect(feed.items[1].reblogs_count).toBe(2)
|
||||
expect(feed.items[2].favourites_count).toBe(6)
|
||||
expect(loader.mock.calls.slice(2).map(([cursor]) => cursor.max_id)).toEqual([
|
||||
undefined,
|
||||
'first',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileRefreshItems', () => {
|
||||
it('removes entries missing inside the refreshed chronological window', () => {
|
||||
const first = entry('first', 12)
|
||||
const deleted = entry('deleted', 11)
|
||||
const last = entry('last', 10)
|
||||
|
||||
expect(reconcileRefreshItems([first, deleted, last], [first, last])).toEqual([
|
||||
first,
|
||||
last,
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves older entries outside the refreshed window', () => {
|
||||
const first = entry('first', 12)
|
||||
const refreshedLast = entry('refreshed-last', 11)
|
||||
const older = entry('older', 8)
|
||||
|
||||
expect(
|
||||
reconcileRefreshItems(
|
||||
[first, older],
|
||||
[entry('new', 13), first, refreshedLast],
|
||||
),
|
||||
).toEqual([entry('new', 13), first, refreshedLast, older])
|
||||
})
|
||||
|
||||
it('removes explicit deletions and repost wrappers anywhere in the list', () => {
|
||||
const deletedOriginal = entry('deleted-original', 4)
|
||||
const wrapper = entry('wrapper', 5, 0, deletedOriginal)
|
||||
const retained = entry('retained', 3)
|
||||
|
||||
expect(
|
||||
reconcileRefreshItems(
|
||||
[wrapper, retained],
|
||||
[retained],
|
||||
['deleted-original'],
|
||||
),
|
||||
).toEqual([retained])
|
||||
})
|
||||
})
|
||||
@@ -17,31 +17,40 @@ const STORAGE_KEY = 'plspace:session'
|
||||
interface PersistedSession {
|
||||
host: string
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
}
|
||||
|
||||
function load(): PersistedSession {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { host: '', token: null }
|
||||
if (!raw) return { host: '', token: null, refreshToken: null }
|
||||
const parsed = JSON.parse(raw) as PersistedSession
|
||||
return { host: normalizeHost(parsed.host ?? ''), token: parsed.token ?? null }
|
||||
return {
|
||||
host: normalizeHost(parsed.host ?? ''),
|
||||
token: typeof parsed.token === 'string' ? parsed.token : null,
|
||||
refreshToken: typeof parsed.refreshToken === 'string' ? parsed.refreshToken : null,
|
||||
}
|
||||
} catch {
|
||||
return { host: '', token: null }
|
||||
return { host: '', token: null, refreshToken: null }
|
||||
}
|
||||
}
|
||||
|
||||
class Session {
|
||||
export class Session {
|
||||
host = $state('')
|
||||
token = $state<string | null>(null)
|
||||
refreshToken = $state<string | null>(null)
|
||||
me = $state<CredentialAccount | null>(null)
|
||||
instance = $state<InstanceInfo | null>(null)
|
||||
private refreshInFlight: Promise<string | null> | null = null
|
||||
|
||||
/** True until the first `restore()` settles, so routes can hold off. */
|
||||
loading = $state(true)
|
||||
error = $state<string | null>(null)
|
||||
|
||||
/** A client bound to the current host and token. Recomputed on change. */
|
||||
readonly api = $derived(new ApiClient(this.host, this.token))
|
||||
readonly api = $derived(
|
||||
new ApiClient(this.host, this.token, () => this.renewAccessToken()),
|
||||
)
|
||||
|
||||
readonly signedIn = $derived(Boolean(this.token && this.me))
|
||||
readonly connected = $derived(Boolean(this.host))
|
||||
@@ -60,12 +69,14 @@ class Session {
|
||||
if (completed) {
|
||||
this.host = completed.host
|
||||
this.token = completed.token
|
||||
this.refreshToken = completed.refreshToken ?? null
|
||||
this.persist()
|
||||
landing = completed.returnTo
|
||||
} else {
|
||||
const stored = load()
|
||||
this.host = stored.host
|
||||
this.token = stored.token
|
||||
this.refreshToken = stored.refreshToken
|
||||
}
|
||||
|
||||
if (!this.host) return landing
|
||||
@@ -80,6 +91,7 @@ class Session {
|
||||
if (cause instanceof ApiError && cause.isAuthFailure) {
|
||||
// Token revoked server-side, or the instance was reinstalled.
|
||||
this.token = null
|
||||
this.refreshToken = null
|
||||
this.me = null
|
||||
this.persist()
|
||||
this.error = 'Your sign-in expired. Please log in again.'
|
||||
@@ -116,6 +128,7 @@ class Session {
|
||||
|
||||
this.host = normalized
|
||||
this.token = null
|
||||
this.refreshToken = null
|
||||
this.me = null
|
||||
this.instance = instance
|
||||
this.error = null
|
||||
@@ -131,6 +144,7 @@ class Session {
|
||||
async logout(): Promise<void> {
|
||||
const { host, token } = this
|
||||
this.token = null
|
||||
this.refreshToken = null
|
||||
this.me = null
|
||||
this.persist()
|
||||
if (host && token) await oauth.revoke(host, token)
|
||||
@@ -145,9 +159,58 @@ class Session {
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
const payload: PersistedSession = { host: this.host, token: this.token }
|
||||
const payload: PersistedSession = {
|
||||
host: this.host,
|
||||
token: this.token,
|
||||
refreshToken: this.refreshToken,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* Egregoros access tokens are intentionally short-lived. Serialize renewal
|
||||
* because several timeline/sidebar requests can discover expiry together,
|
||||
* and Egregoros rotates each refresh token exactly once.
|
||||
*/
|
||||
private renewAccessToken(): Promise<string | null> {
|
||||
if (this.refreshInFlight) return this.refreshInFlight
|
||||
if (!this.host || !this.refreshToken) return Promise.resolve(null)
|
||||
|
||||
const attempt = this.performTokenRenewal()
|
||||
this.refreshInFlight = attempt
|
||||
void attempt.finally(() => {
|
||||
if (this.refreshInFlight === attempt) this.refreshInFlight = null
|
||||
})
|
||||
return attempt
|
||||
}
|
||||
|
||||
private async performTokenRenewal(): Promise<string | null> {
|
||||
const host = this.host
|
||||
const refreshToken = this.refreshToken
|
||||
if (!host || !refreshToken) return null
|
||||
|
||||
try {
|
||||
const refreshed = await oauth.refreshAccessToken(host, refreshToken)
|
||||
if (this.host !== host || this.refreshToken !== refreshToken) return null
|
||||
|
||||
this.token = refreshed.token
|
||||
this.refreshToken = refreshed.refreshToken ?? refreshToken
|
||||
this.persist()
|
||||
return refreshed.token
|
||||
} catch (cause) {
|
||||
// A transient CORS/offline failure should not erase a renewable session.
|
||||
// A server response means the rotating token is no longer usable.
|
||||
if (!(cause instanceof ApiError) || cause.status !== 0) {
|
||||
if (this.host === host && this.refreshToken === refreshToken) {
|
||||
this.token = null
|
||||
this.refreshToken = null
|
||||
this.me = null
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const session = new Session()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Session } from './session.svelte'
|
||||
|
||||
const SESSION_KEY = 'plspace:session'
|
||||
const APP_KEY = 'plspace:oauth:apps'
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Session Egregoros compatibility', () => {
|
||||
it('persists and automatically rotates a refresh token after an expired API request', async () => {
|
||||
localStorage.setItem(
|
||||
SESSION_KEY,
|
||||
JSON.stringify({
|
||||
host: 'egregoros.example',
|
||||
token: 'expired-access-token',
|
||||
refreshToken: 'first-refresh-token',
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
APP_KEY,
|
||||
JSON.stringify({
|
||||
'egregoros.example': {
|
||||
id: 'app-1',
|
||||
name: 'plspace',
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
redirect_uri: `${window.location.origin}${window.location.pathname}`,
|
||||
plspace_redirect_uri: `${window.location.origin}${window.location.pathname}`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const verifyCalls: string[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/api/v2/instance')) {
|
||||
return json({ title: 'Egregoros', version: 'egregoros/0.1.0' })
|
||||
}
|
||||
if (url.endsWith('/oauth/token')) {
|
||||
expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toMatchObject({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'first-refresh-token',
|
||||
})
|
||||
return json({
|
||||
access_token: 'fresh-access-token',
|
||||
refresh_token: 'rotated-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
scope: 'read write follow',
|
||||
created_at: 123,
|
||||
expires_in: 3600,
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/accounts/verify_credentials')) {
|
||||
const authorization = (init?.headers as Headers).get('Authorization') ?? ''
|
||||
verifyCalls.push(authorization)
|
||||
if (authorization === 'Bearer expired-access-token') {
|
||||
return json({ error: 'Invalid or expired token' }, 401)
|
||||
}
|
||||
return json({
|
||||
id: 'account-1',
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
display_name: 'Alice',
|
||||
note: '',
|
||||
url: 'https://egregoros.example/@alice',
|
||||
avatar: '',
|
||||
avatar_static: '',
|
||||
header: '',
|
||||
header_static: '',
|
||||
locked: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
statuses_count: 0,
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
fields: [],
|
||||
emojis: [],
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}),
|
||||
)
|
||||
|
||||
const session = new Session()
|
||||
await session.restore()
|
||||
|
||||
expect(verifyCalls).toEqual([
|
||||
'Bearer expired-access-token',
|
||||
'Bearer fresh-access-token',
|
||||
])
|
||||
expect(session.token).toBe('fresh-access-token')
|
||||
expect(session.signedIn).toBe(true)
|
||||
expect(JSON.parse(String(localStorage.getItem(SESSION_KEY)))).toEqual({
|
||||
host: 'egregoros.example',
|
||||
token: 'fresh-access-token',
|
||||
refreshToken: 'rotated-refresh-token',
|
||||
})
|
||||
})
|
||||
})
|
||||
+226
-10
@@ -16,6 +16,8 @@
|
||||
* run script.
|
||||
*/
|
||||
|
||||
import type { InstanceInfo } from '../api/types'
|
||||
|
||||
const VIEWER_STYLE_ID = 'user-stylesheet'
|
||||
const PROFILE_STYLE_ID = 'profile-stylesheet'
|
||||
const STORAGE_KEY = 'plspace:viewer-css'
|
||||
@@ -26,6 +28,228 @@ export const PROFILE_SCOPE = '.profile-page'
|
||||
/** Field names checked, in order, for a profile's published stylesheet. */
|
||||
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet']
|
||||
|
||||
const NAMESPACED_CSS_FIELD_PATTERN = /^plspace:css(\d+)$/i
|
||||
const LEGACY_CSS_FIELD_PATTERN = /^(css|style|layout|stylesheet)(?:[\s_-]+(\d+))?$/i
|
||||
const ENCODED_CSS_PREFIX = 'plspace-css-b64:'
|
||||
|
||||
export interface ProfileFieldLimits {
|
||||
maxFields: number
|
||||
nameLength: number
|
||||
valueLength: number
|
||||
}
|
||||
|
||||
/** Normalize Mastodon v2 and Pleroma extension shapes, with safe old-server defaults. */
|
||||
export function profileFieldLimits(instance: InstanceInfo | null): ProfileFieldLimits {
|
||||
const accounts = instance?.configuration?.accounts
|
||||
const pleroma = instance?.pleroma?.metadata?.fields_limits
|
||||
const isPleroma = Boolean(instance?.pleroma)
|
||||
return {
|
||||
maxFields: accounts?.max_profile_fields ?? pleroma?.max_fields ?? (isPleroma ? 10 : 4),
|
||||
nameLength:
|
||||
accounts?.profile_field_name_limit ??
|
||||
accounts?.max_profile_field_name_length ??
|
||||
pleroma?.name_length ??
|
||||
255,
|
||||
valueLength:
|
||||
accounts?.profile_field_value_limit ??
|
||||
accounts?.max_profile_field_value_length ??
|
||||
pleroma?.value_length ??
|
||||
(isPleroma ? 2048 : 255),
|
||||
}
|
||||
}
|
||||
|
||||
function fieldText(field: { value: string }): string {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = field.value
|
||||
for (const br of Array.from(container.querySelectorAll('br'))) {
|
||||
br.replaceWith(document.createTextNode('\n'))
|
||||
}
|
||||
for (const block of Array.from(container.querySelectorAll('p, div, li'))) {
|
||||
if (block.nextSibling) block.append(document.createTextNode('\n'))
|
||||
}
|
||||
return container.textContent ?? ''
|
||||
}
|
||||
|
||||
function cssField(
|
||||
field: { name: string },
|
||||
): { namespace: 'plspace' | 'legacy'; base: string; part: number } | null {
|
||||
const name = field.name.trim()
|
||||
const namespaced = NAMESPACED_CSS_FIELD_PATTERN.exec(name)
|
||||
if (namespaced) {
|
||||
return { namespace: 'plspace', base: 'css', part: Number(namespaced[1]) }
|
||||
}
|
||||
const legacy = LEGACY_CSS_FIELD_PATTERN.exec(name)
|
||||
if (!legacy) return null
|
||||
return {
|
||||
namespace: 'legacy',
|
||||
base: legacy[1].toLowerCase(),
|
||||
part: legacy[2] ? Number(legacy[2]) : 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reassemble a single CSS field or the ordered chunks written by plspace. */
|
||||
export function publishedCssFromFields(
|
||||
fields: Array<{ name: string; value: string }> | undefined,
|
||||
): string {
|
||||
if (!fields?.length) return ''
|
||||
const namespaced = fields
|
||||
.map((field, index) => ({ field, index, parsed: cssField(field) }))
|
||||
.filter((entry) => entry.parsed?.namespace === 'plspace')
|
||||
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
|
||||
if (namespaced.length > 0) {
|
||||
const values = namespaced.map(({ field }) => fieldText(field))
|
||||
if (values.every((value) => value.startsWith(ENCODED_CSS_PREFIX))) {
|
||||
try {
|
||||
return decodeCss(
|
||||
values.map((value) => value.slice(ENCODED_CSS_PREFIX.length)).join(''),
|
||||
).trim()
|
||||
} catch {
|
||||
// Keep profiles readable if a field was edited or truncated elsewhere.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return values.join('').trim()
|
||||
}
|
||||
|
||||
for (const preferred of CSS_FIELD_NAMES) {
|
||||
const chunks = fields
|
||||
.map((field, index) => ({ field, index, parsed: cssField(field) }))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.parsed?.namespace === 'legacy' && entry.parsed.base === preferred,
|
||||
)
|
||||
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
|
||||
if (chunks.length > 0) return chunks.map(({ field }) => fieldText(field)).join('').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers normalize bare LF line endings to CRLF when serializing FormData,
|
||||
* and Pleroma validates the decoded multipart value. Budget two characters for
|
||||
* each newline so a nominal 2,048-character chunk cannot arrive over the limit.
|
||||
*
|
||||
* Split on rule boundaries when possible while otherwise retaining the source.
|
||||
* Chunks are concatenated without a separator, including when a very long
|
||||
* quoted data URI has to be split in its middle.
|
||||
*/
|
||||
export function splitPublishedCss(css: string, limit: number): string[] {
|
||||
const source = css.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return []
|
||||
const safeLimit = Math.max(1, Math.floor(limit))
|
||||
const chunks: string[] = []
|
||||
let rest = source
|
||||
|
||||
while (multipartFieldLength(rest) > safeLimit) {
|
||||
let windowEnd = 0
|
||||
let encodedLength = 0
|
||||
while (windowEnd < rest.length) {
|
||||
const width = rest[windowEnd] === '\n' ? 2 : 1
|
||||
if (encodedLength + width > safeLimit) break
|
||||
encodedLength += width
|
||||
windowEnd += 1
|
||||
}
|
||||
// A one-character limit can still make progress on a newline.
|
||||
if (windowEnd === 0) windowEnd = 1
|
||||
|
||||
const window = rest.slice(0, windowEnd)
|
||||
let cut = window.lastIndexOf('}')
|
||||
if (cut >= 0) cut += 1
|
||||
else {
|
||||
cut = window.lastIndexOf('\n')
|
||||
if (cut <= 0) cut = windowEnd
|
||||
}
|
||||
chunks.push(rest.slice(0, cut))
|
||||
rest = rest.slice(cut)
|
||||
}
|
||||
if (rest) chunks.push(rest)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** Length after the newline normalization used for multipart form fields. */
|
||||
export function multipartFieldLength(value: string): number {
|
||||
let length = value.length
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] === '\n' && value[index - 1] !== '\r') length += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
function encodeCss(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
let binary = ''
|
||||
const windowSize = 0x8000
|
||||
for (let index = 0; index < bytes.length; index += windowSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + windowSize))
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function decodeCss(value: string): string {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode published CSS before placing it in profile fields.
|
||||
*
|
||||
* Pleroma linkifies raw profile-field values before enforcing its configured
|
||||
* value limit. CSS commonly contains URLs and hashtag-shaped tokens such as
|
||||
* `#fff`, so a raw value under the advertised limit can expand into HTML over
|
||||
* that limit. Base64url has no linkifiable syntax, making its server-side
|
||||
* length identical to the length measured here.
|
||||
*/
|
||||
export function publishedCssFieldValues(css: string, limit: number): string[] {
|
||||
const source = css.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return []
|
||||
|
||||
const capacity = Math.floor(limit) - ENCODED_CSS_PREFIX.length
|
||||
if (capacity < 1) {
|
||||
throw new Error('This server’s profile field-value limit is too small for encoded CSS.')
|
||||
}
|
||||
|
||||
const encoded = encodeCss(source)
|
||||
const values: string[] = []
|
||||
for (let index = 0; index < encoded.length; index += capacity) {
|
||||
values.push(ENCODED_CSS_PREFIX + encoded.slice(index, index + capacity))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve normal profile fields and replace all prior CSS chunks atomically.
|
||||
* Legacy CSS fields are migrated to the unambiguous `plspace:css1` namespace.
|
||||
*/
|
||||
export function replacePublishedCssFields(
|
||||
fields: Array<{ name: string; value: string }>,
|
||||
css: string,
|
||||
limits: ProfileFieldLimits,
|
||||
): Array<{ name: string; value: string }> {
|
||||
const existingCss = fields.map((field) => ({ field, parsed: cssField(field) }))
|
||||
const ordinary = existingCss
|
||||
.filter((entry) => !entry.parsed)
|
||||
.map(({ field }) => ({ name: field.name, value: field.value }))
|
||||
const chunks = publishedCssFieldValues(css, limits.valueLength)
|
||||
const cssFields = chunks.map((value, index) => ({
|
||||
name: `plspace:css${index + 1}`,
|
||||
value,
|
||||
}))
|
||||
|
||||
if (ordinary.length + cssFields.length > limits.maxFields) {
|
||||
const available = Math.max(0, limits.maxFields - ordinary.length)
|
||||
throw new Error(
|
||||
`This server has room for ${available} CSS field${available === 1 ? '' : 's'}, but this stylesheet needs ${cssFields.length}. Shorten it or remove another profile field.`,
|
||||
)
|
||||
}
|
||||
if (cssFields.some((field) => field.name.length > limits.nameLength)) {
|
||||
throw new Error('This server’s profile field-name limit is too small for chunked CSS.')
|
||||
}
|
||||
return [...ordinary, ...cssFields]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (or create) a style element, always moving it to the end of `<head>`.
|
||||
*
|
||||
@@ -194,14 +418,6 @@ export const theme = new Theme()
|
||||
export function profileCssFromFields(
|
||||
fields: Array<{ name: string; value: string }> | undefined,
|
||||
): string | null {
|
||||
if (!fields?.length) return null
|
||||
for (const field of fields) {
|
||||
if (!CSS_FIELD_NAMES.includes(field.name.trim().toLowerCase())) continue
|
||||
// Field values arrive as HTML; take the text and undo entity escaping.
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = field.value
|
||||
const text = (container.textContent ?? '').trim()
|
||||
if (text.includes('{')) return text
|
||||
}
|
||||
return null
|
||||
const css = publishedCssFromFields(fields)
|
||||
return css.includes('{') ? css : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
profileCssFromFields,
|
||||
profileFieldLimits,
|
||||
multipartFieldLength,
|
||||
publishedCssFieldValues,
|
||||
publishedCssFromFields,
|
||||
replacePublishedCssFields,
|
||||
splitPublishedCss,
|
||||
} from './theme.svelte'
|
||||
|
||||
describe('published profile CSS fields', () => {
|
||||
it('reads ordinary and chunked CSS fields in numeric order', () => {
|
||||
const fields = [
|
||||
{ name: 'Website', value: '<a href="https://example.test">example</a>' },
|
||||
{ name: 'plspace:css3', value: '<p>.three { color: blue; }</p>' },
|
||||
{ name: 'plspace:css1', value: '<p>.one { color: red; }</p>' },
|
||||
{ name: 'plspace:css2', value: '<p>.two { color: green; }</p>' },
|
||||
]
|
||||
|
||||
expect(publishedCssFromFields(fields)).toBe(
|
||||
'.one { color: red; }.two { color: green; }.three { color: blue; }',
|
||||
)
|
||||
expect(profileCssFromFields(fields)).toContain('.three { color: blue; }')
|
||||
})
|
||||
|
||||
it('splits long stylesheets on complete rule boundaries', () => {
|
||||
const css = '.one { color: red; }\n.two { color: green; }\n.three { color: blue; }'
|
||||
const chunks = splitPublishedCss(css, 42)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 42)).toBe(true)
|
||||
expect(chunks.join('')).toBe(css)
|
||||
})
|
||||
|
||||
it('budgets for FormData expanding line feeds to CRLF', () => {
|
||||
const css = Array.from({ length: 20 }, (_, index) => `.r${index} {\n color: pink;\n}`).join(
|
||||
'\n',
|
||||
)
|
||||
const chunks = splitPublishedCss(css, 80)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 80)).toBe(true)
|
||||
expect(chunks.join('')).toBe(css)
|
||||
})
|
||||
|
||||
it('stores CSS as non-linkifiable chunks and decodes Unicode exactly', () => {
|
||||
const css =
|
||||
'.profile { color: #fff; background: url("https://example.test/image.png"); }\n/* 日本語 🌸 */'
|
||||
const values = publishedCssFieldValues(css, 48)
|
||||
|
||||
expect(values.length).toBeGreaterThan(1)
|
||||
expect(values.every((value) => value.length <= 48)).toBe(true)
|
||||
expect(values.every((value) => /^plspace-css-b64:[A-Za-z0-9_-]+$/.test(value))).toBe(true)
|
||||
expect(
|
||||
publishedCssFromFields(
|
||||
values.map((value, index) => ({ name: `plspace:css${index + 1}`, value })),
|
||||
),
|
||||
).toBe(css)
|
||||
})
|
||||
|
||||
it('replaces old CSS chunks while preserving unrelated profile fields', () => {
|
||||
const result = replacePublishedCssFields(
|
||||
[
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'style', value: 'old' },
|
||||
{ name: 'style 2', value: 'old continuation' },
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
],
|
||||
'.one { color: red; }\n.two { color: green; }',
|
||||
{ maxFields: 6, nameLength: 255, valueLength: 55 },
|
||||
)
|
||||
|
||||
expect(result.slice(0, 2)).toEqual([
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
])
|
||||
expect(result.slice(2).map((field) => field.name)).toEqual([
|
||||
'plspace:css1',
|
||||
'plspace:css2',
|
||||
])
|
||||
expect(publishedCssFromFields(result.slice(2))).toBe(
|
||||
'.one { color: red; }\n.two { color: green; }',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes only CSS fields when publishing an empty value', () => {
|
||||
expect(
|
||||
replacePublishedCssFields(
|
||||
[
|
||||
{ name: 'css', value: 'body {}' },
|
||||
{ name: 'plspace:css2', value: '.more {}' },
|
||||
{ name: 'Location', value: 'Tokyo' },
|
||||
],
|
||||
'',
|
||||
{ maxFields: 4, nameLength: 255, valueLength: 255 },
|
||||
),
|
||||
).toEqual([{ name: 'Location', value: 'Tokyo' }])
|
||||
})
|
||||
|
||||
it('rejects a stylesheet that exceeds the server field count', () => {
|
||||
expect(() =>
|
||||
replacePublishedCssFields(
|
||||
[{ name: 'Website', value: 'https://example.test' }],
|
||||
'.a{color:red}.b{color:blue}.c{color:green}',
|
||||
{ maxFields: 2, nameLength: 255, valueLength: 30 },
|
||||
),
|
||||
).toThrow(/room for 1 CSS field/)
|
||||
})
|
||||
|
||||
it('normalizes both Mastodon and Pleroma field-limit shapes', () => {
|
||||
expect(
|
||||
profileFieldLimits({
|
||||
title: 'Mastodon',
|
||||
version: '4.5.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 4,
|
||||
max_profile_field_name_length: 255,
|
||||
max_profile_field_value_length: 500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ maxFields: 4, nameLength: 255, valueLength: 500 })
|
||||
|
||||
expect(
|
||||
profileFieldLimits({
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 10,
|
||||
profile_field_name_limit: 512,
|
||||
profile_field_value_limit: 2048,
|
||||
},
|
||||
},
|
||||
pleroma: {
|
||||
metadata: {
|
||||
fields_limits: { max_fields: 9, name_length: 500, value_length: 2000 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ maxFields: 10, nameLength: 512, valueLength: 2048 })
|
||||
})
|
||||
})
|
||||
+25
-1
@@ -41,7 +41,31 @@ const ALLOWED_TAGS = [
|
||||
'img',
|
||||
]
|
||||
|
||||
const ALLOWED_ATTR = ['href', 'rel', 'class', 'title', 'lang', 'src', 'alt', 'draggable', 'data-plspace-to']
|
||||
const ALLOWED_ATTR = [
|
||||
'href',
|
||||
'rel',
|
||||
'class',
|
||||
'title',
|
||||
'lang',
|
||||
'src',
|
||||
'alt',
|
||||
'draggable',
|
||||
'data-plspace-to',
|
||||
// FEP-c16b HTML emitted by Pleroma's MFM parser. These are inert here;
|
||||
// MfmContent validates and interprets them after this sanitization pass.
|
||||
'data-mfm-x',
|
||||
'data-mfm-y',
|
||||
'data-mfm-deg',
|
||||
'data-mfm-color',
|
||||
'data-mfm-speed',
|
||||
'data-mfm-delay',
|
||||
'data-mfm-left',
|
||||
'data-mfm-alternate',
|
||||
'data-mfm-width',
|
||||
'data-mfm-style',
|
||||
'data-mfm-radius',
|
||||
'data-mfm-noclip',
|
||||
]
|
||||
|
||||
/**
|
||||
* Rewrite outbound anchors:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMfmHtml } from './mfm'
|
||||
|
||||
function renderedElement(source: string): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderMfmHtml(source)
|
||||
const element = container.firstElementChild
|
||||
if (!(element instanceof HTMLElement)) throw new Error('Expected one rendered element')
|
||||
return element
|
||||
}
|
||||
|
||||
describe('Pleroma-FE MFM compatibility', () => {
|
||||
it.each([
|
||||
['position', 'data-mfm-x="2" data-mfm-y="-1"', 'translate(calc(2 * (var(--emoji-size) / 2)), calc(-1 * (var(--emoji-size) / 2)))'],
|
||||
['scale', 'data-mfm-x="2" data-mfm-y="3"', 'scale(2, 3)'],
|
||||
['rotate', 'data-mfm-deg="45"', 'rotate(45deg)'],
|
||||
['flip', '', 'scaleX(-1)'],
|
||||
])('renders the %s transform', (operator, attributes, expected) => {
|
||||
const element = renderedElement(`<span class="mfm-${operator}" ${attributes}>effect</span>`)
|
||||
expect(element.dataset.mfmOperator).toBe(operator)
|
||||
expect(element.style.transform).toBe(expected)
|
||||
})
|
||||
|
||||
it('renders foreground and background colors', () => {
|
||||
const fg = renderedElement('<span class="mfm-fg" data-mfm-color="12abEF">fg</span>')
|
||||
const bg = renderedElement('<span class="mfm-bg" data-mfm-color="fed">bg</span>')
|
||||
expect(fg.style.color).toBe('rgb(18, 171, 239)')
|
||||
expect(bg.style.backgroundColor).toBe('rgb(255, 238, 221)')
|
||||
})
|
||||
|
||||
it.each(['tada', 'jelly', 'twitch', 'shake', 'jump', 'bounce', 'rainbow'])(
|
||||
'renders the %s looping animation',
|
||||
(operator) => {
|
||||
const element = renderedElement(
|
||||
`<span class="mfm-${operator}" data-mfm-speed="2s" data-mfm-delay="100ms">effect</span>`,
|
||||
)
|
||||
expect(element.style.animationName).toBe(`mfm-${operator}`)
|
||||
expect(element.style.animationDuration).toBe('2s')
|
||||
expect(element.style.animationDelay).toBe('100ms')
|
||||
},
|
||||
)
|
||||
|
||||
it('chooses spin axis and direction with Pleroma-FE precedence', () => {
|
||||
const element = renderedElement(
|
||||
'<span class="mfm-spin" data-mfm-x data-mfm-y data-mfm-left data-mfm-alternate>spin</span>',
|
||||
)
|
||||
expect(element.style.animationName).toBe('mfm-spinX')
|
||||
expect(element.style.animationDirection).toBe('alternate')
|
||||
})
|
||||
|
||||
it('renders border attributes and noclip', () => {
|
||||
const element = renderedElement(
|
||||
'<span class="mfm-border" data-mfm-width="2px" data-mfm-style="dashed" data-mfm-color="#123456" data-mfm-radius="0.5em" data-mfm-noclip>box</span>',
|
||||
)
|
||||
expect(element.style.border).toBe('2px dashed rgb(18, 52, 86)')
|
||||
expect(element.style.borderRadius).toBe('0.5em')
|
||||
expect(element.style.overflow).toBe('visible')
|
||||
})
|
||||
|
||||
it.each(['sparkle', 'x2', 'x3', 'x4'])(
|
||||
'leaves the %s operator for the matching Pleroma-FE CSS rule',
|
||||
(operator) => {
|
||||
const element = renderedElement(`<span class="mfm-${operator}">effect</span>`)
|
||||
expect(element).toHaveClass('mfm', '-pause')
|
||||
expect(element.dataset.mfmOperator).toBe(operator)
|
||||
expect(element.getAttribute('style')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('supports nested server-rendered MFM spans', () => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderMfmHtml(
|
||||
'<span class="mfm-x2">outer <span class="mfm-spin">inner</span></span>',
|
||||
)
|
||||
expect(container.querySelectorAll('.mfm')).toHaveLength(2)
|
||||
expect(container.querySelector('[data-mfm-operator="spin"]')).toHaveStyle({
|
||||
animationName: 'mfm-spin',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not interpret raw MFM source—the Pleroma backend owns parsing', () => {
|
||||
const html = renderMfmHtml('$[spin.speed=1s plain source]')
|
||||
expect(html).toBe('$[spin.speed=1s plain source]')
|
||||
})
|
||||
|
||||
it('rejects CSS injection in FEP attributes while keeping the effect', () => {
|
||||
const element = renderedElement(
|
||||
'<span class="mfm-spin" data-mfm-speed="1s;color:red" data-mfm-delay="0;display:none">safe</span>',
|
||||
)
|
||||
expect(element.style.animationDuration).toBe('1s')
|
||||
expect(element.getAttribute('style')).toContain('animation-delay: 0')
|
||||
expect(element.style.color).toBe('')
|
||||
expect(element.style.display).toBe('')
|
||||
})
|
||||
|
||||
it('drops source style and event handlers before adding trusted effect styles', () => {
|
||||
const element = renderedElement(
|
||||
'<span class="mfm-flip" style="position:fixed" onclick="alert(1)">safe</span>',
|
||||
)
|
||||
expect(element).not.toHaveAttribute('onclick')
|
||||
expect(element.style.position).toBe('')
|
||||
expect(element.style.transform).toBe('scaleX(-1)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Pleroma-FE-compatible rendering for FEP-c16b HTML.
|
||||
*
|
||||
* Pleroma parses raw `$[operator.attributes content]` MFM on the server and
|
||||
* exposes HTML spans with `mfm-*` classes and `data-mfm-*` attributes through
|
||||
* the Mastodon API. This mirrors Pleroma-FE's RichContent transformation rather
|
||||
* than attempting to parse raw MFM a second time in the browser.
|
||||
*/
|
||||
|
||||
import type { RenderOptions } from './html'
|
||||
import { renderHtml } from './html'
|
||||
|
||||
export interface MfmRenderOptions extends RenderOptions {
|
||||
/** Match Pleroma-FE's default: animations resume when RichText is hovered. */
|
||||
pause?: boolean
|
||||
/** Scale effects against the surrounding custom-emoji size. */
|
||||
scale?: boolean
|
||||
}
|
||||
|
||||
const LOOPING_OPERATORS = new Set([
|
||||
'tada',
|
||||
'jelly',
|
||||
'twitch',
|
||||
'shake',
|
||||
'jump',
|
||||
'bounce',
|
||||
'rainbow',
|
||||
])
|
||||
|
||||
function numberAttribute(element: Element, name: string, fallback: number): number {
|
||||
const parsed = Number.parseFloat(element.getAttribute(name) ?? '')
|
||||
return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function timeAttribute(element: Element, name: string, fallback: string): string {
|
||||
const value = element.getAttribute(name)?.trim()
|
||||
return value && /^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:ms|s)|0)$/i.test(value)
|
||||
? value
|
||||
: fallback
|
||||
}
|
||||
|
||||
function hexAttribute(element: Element, name: string, fallback: string): string {
|
||||
const value = element.getAttribute(name)?.trim()
|
||||
return value && /^[0-9a-f]{1,8}$/i.test(value) ? value : fallback
|
||||
}
|
||||
|
||||
function lengthAttribute(element: Element, name: string, fallback: string): string {
|
||||
const value = element.getAttribute(name)?.trim()
|
||||
return value &&
|
||||
/^(?:0|-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|ex|ch|%|vw|vh|vmin|vmax)?)$/i.test(value)
|
||||
? value
|
||||
: fallback
|
||||
}
|
||||
|
||||
function borderColorAttribute(element: Element): string {
|
||||
const value = element.getAttribute('data-mfm-color')?.trim()
|
||||
if (!value) return 'transparent'
|
||||
if (/^#?[0-9a-f]{3,8}$/i.test(value) || /^(?:transparent|currentcolor)$/i.test(value)) {
|
||||
return value
|
||||
}
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
function borderStyleAttribute(element: Element): string {
|
||||
const value = element.getAttribute('data-mfm-style')?.trim().toLowerCase()
|
||||
return value &&
|
||||
/^(?:none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(value)
|
||||
? value
|
||||
: 'solid'
|
||||
}
|
||||
|
||||
function animationRules(name: string, speed: string, delay: string, direction = 'normal'): string {
|
||||
return [
|
||||
`animation-name: ${name}`,
|
||||
`animation-duration: ${speed}`,
|
||||
'animation-iteration-count: infinite',
|
||||
`animation-delay: ${delay}`,
|
||||
`animation-direction: ${direction}`,
|
||||
'animation-fill-mode: none',
|
||||
'animation-timing-function: linear',
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/** Enhance already-sanitized FEP-c16b spans using Pleroma-FE's operator rules. */
|
||||
export function enhanceMfmHtml(html: string, options: Pick<MfmRenderOptions, 'pause' | 'scale'> = {}): string {
|
||||
if (!html || !html.includes('mfm-')) return html
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = html
|
||||
|
||||
for (const element of Array.from(container.querySelectorAll('[class^="mfm-"]'))) {
|
||||
const originalClass = element.getAttribute('class') ?? ''
|
||||
const operator = /^mfm-(\w+)$/.exec(originalClass)?.[1]
|
||||
if (!operator) continue
|
||||
|
||||
element.setAttribute(
|
||||
'class',
|
||||
['mfm', options.pause !== false ? '-pause' : '', options.scale ? '-scale' : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
element.setAttribute('data-mfm-operator', operator)
|
||||
|
||||
let style = ''
|
||||
switch (operator) {
|
||||
case 'position': {
|
||||
const x = numberAttribute(element, 'data-mfm-x', 0)
|
||||
const y = numberAttribute(element, 'data-mfm-y', 0)
|
||||
style = `transform: translate(calc(${x} * (var(--emoji-size) / 2)), calc(${y} * (var(--emoji-size) / 2)))`
|
||||
break
|
||||
}
|
||||
case 'scale': {
|
||||
const x = numberAttribute(element, 'data-mfm-x', 1)
|
||||
const y = numberAttribute(element, 'data-mfm-y', 1)
|
||||
style = `transform: scale(${x}, ${y})`
|
||||
break
|
||||
}
|
||||
case 'rotate': {
|
||||
const degrees = numberAttribute(element, 'data-mfm-deg', 0)
|
||||
style = `transform: rotate(${degrees}deg);transform-origin: center`
|
||||
break
|
||||
}
|
||||
case 'bg':
|
||||
style = `background-color: #${hexAttribute(element, 'data-mfm-color', '0')}`
|
||||
break
|
||||
case 'fg':
|
||||
style = `color: #${hexAttribute(element, 'data-mfm-color', '0')}`
|
||||
break
|
||||
case 'spin': {
|
||||
const speed = timeAttribute(element, 'data-mfm-speed', '1s')
|
||||
const delay = timeAttribute(element, 'data-mfm-delay', '0')
|
||||
const animation = element.hasAttribute('data-mfm-x')
|
||||
? 'mfm-spinX'
|
||||
: element.hasAttribute('data-mfm-y')
|
||||
? 'mfm-spinY'
|
||||
: 'mfm-spin'
|
||||
const direction = element.hasAttribute('data-mfm-alternate')
|
||||
? 'alternate'
|
||||
: element.hasAttribute('data-mfm-left')
|
||||
? 'reverse'
|
||||
: 'normal'
|
||||
style = animationRules(animation, speed, delay, direction)
|
||||
break
|
||||
}
|
||||
case 'flip':
|
||||
style = 'transform: scaleX(-1)'
|
||||
break
|
||||
case 'border': {
|
||||
const width = lengthAttribute(element, 'data-mfm-width', '0')
|
||||
const borderStyle = borderStyleAttribute(element)
|
||||
const color = borderColorAttribute(element)
|
||||
const radius = lengthAttribute(element, 'data-mfm-radius', '0')
|
||||
const overflow = element.hasAttribute('data-mfm-noclip') ? 'visible' : 'clip'
|
||||
style = `border: ${width} ${borderStyle} ${color};border-radius: ${radius};overflow: ${overflow}`
|
||||
break
|
||||
}
|
||||
default:
|
||||
if (LOOPING_OPERATORS.has(operator)) {
|
||||
const speed = timeAttribute(element, 'data-mfm-speed', '1s')
|
||||
const delay = timeAttribute(element, 'data-mfm-delay', '0')
|
||||
style = animationRules(`mfm-${operator}`, speed, delay)
|
||||
}
|
||||
}
|
||||
|
||||
if (style) element.setAttribute('style', style)
|
||||
}
|
||||
|
||||
return container.innerHTML
|
||||
}
|
||||
|
||||
/** Sanitize, emojify, relink, then apply Pleroma-FE's MFM presentation. */
|
||||
export function renderMfmHtml(
|
||||
source: string | null | undefined,
|
||||
options: MfmRenderOptions = {},
|
||||
): string {
|
||||
return enhanceMfmHtml(renderHtml(source, options), options)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { account } from '$test/fixtures'
|
||||
import { buildProfileView } from './profile'
|
||||
|
||||
describe('buildProfileView', () => {
|
||||
it('hides namespaced plspace storage fields from visible profile details', () => {
|
||||
const profile = buildProfileView(
|
||||
account({
|
||||
fields: [
|
||||
{ name: 'Website', value: '<a href="https://example.test">example.test</a>' },
|
||||
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
|
||||
{ name: ' PLSPACE:future-setting ', value: 'internal' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(profile.details).toHaveLength(1)
|
||||
expect(profile.details[0].name).toBe('Website')
|
||||
expect(profile.details[0].value).toContain('example.test')
|
||||
})
|
||||
})
|
||||
@@ -132,6 +132,10 @@ export function buildProfileView(account: Account): ProfileView {
|
||||
|
||||
for (const field of account.fields ?? []) {
|
||||
const key = field.name.trim().toLowerCase()
|
||||
// `plspace:` fields are application storage, not user-facing profile
|
||||
// details. Keep them on the Account for features such as published CSS,
|
||||
// but never turn them into visible name/value rows.
|
||||
if (key.startsWith('plspace:')) continue
|
||||
if (CHROME_FIELDS.has(key)) continue
|
||||
|
||||
const value = renderHtml(field.value, { emojis: account.emojis })
|
||||
|
||||
+15
-3
@@ -18,9 +18,11 @@
|
||||
import { toPlainText } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||
import { reconcileRefreshItems } from '$lib/stores/feed.svelte'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import RichText from '$components/common/RichText.svelte'
|
||||
import MfmContent from '$components/common/MfmContent.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
@@ -155,7 +157,12 @@
|
||||
session.host === host &&
|
||||
session.signedIn === signedIn
|
||||
) {
|
||||
friendStatus = page.items
|
||||
friendStatus = reconcileRefreshItems(
|
||||
friendStatus,
|
||||
page.items,
|
||||
page.deletedIds,
|
||||
{ emptyIsAuthoritative: !page.links.maxId },
|
||||
)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (generation === primaryRefreshGeneration && session.host === host) {
|
||||
@@ -173,7 +180,12 @@
|
||||
session.host === host &&
|
||||
session.signedIn === signedIn
|
||||
) {
|
||||
bulletins = page.items
|
||||
bulletins = reconcileRefreshItems(
|
||||
bulletins,
|
||||
page.items,
|
||||
page.deletedIds,
|
||||
{ emptyIsAuthoritative: !page.links.maxId },
|
||||
)
|
||||
}
|
||||
} catch (cause) {
|
||||
if (generation === primaryRefreshGeneration && session.host === host) {
|
||||
@@ -317,7 +329,7 @@
|
||||
<a class="status-line-author" href={profilePath(entry.account)}>
|
||||
{displayNameOf(entry.account)}
|
||||
</a>
|
||||
<RichText
|
||||
<MfmContent
|
||||
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
|
||||
emojis={entry.emojis}
|
||||
mentions={entry.mentions}
|
||||
|
||||
@@ -122,10 +122,10 @@
|
||||
<p>
|
||||
plspace registers itself as an application on your server, then sends you there to approve
|
||||
it. Your password is never typed into plspace — you enter it on your own server, and
|
||||
plspace only ever receives an access token.
|
||||
plspace only ever receives OAuth tokens.
|
||||
</p>
|
||||
<p>
|
||||
That token is stored in this browser's local storage and used directly from your browser.
|
||||
Those tokens are stored in this browser's local storage and used directly from your browser.
|
||||
There is no plspace backend; nothing you read or post passes through anyone else's server.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
const currentView = view
|
||||
const loaded = account !== null
|
||||
if (!timelineRefresh || !loaded || currentView === 'friends') return
|
||||
return timelineRefresh.register(() => void entries.reload())
|
||||
return timelineRefresh.register(() => void entries.refresh())
|
||||
})
|
||||
|
||||
async function load(
|
||||
|
||||
@@ -143,10 +143,28 @@ describe('Profile', () => {
|
||||
|
||||
it('refreshes only the entry feed without reloading the profile', async () => {
|
||||
const lookupAccount = vi.fn().mockResolvedValue(account())
|
||||
const fetchAccountStatuses = vi.fn().mockResolvedValue({
|
||||
items: [status({ content: '<p>Timeline note</p>' })],
|
||||
links: {},
|
||||
})
|
||||
const fetchAccountStatuses = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
status({
|
||||
id: 'timeline-note',
|
||||
content: '<p>Timeline note</p>',
|
||||
favourites_count: 0,
|
||||
}),
|
||||
],
|
||||
links: {},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
status({
|
||||
id: 'timeline-note',
|
||||
content: '<p>Timeline note</p>',
|
||||
favourites_count: 5,
|
||||
}),
|
||||
],
|
||||
links: {},
|
||||
})
|
||||
const services = testServices({
|
||||
session: session(),
|
||||
theme: theme(),
|
||||
@@ -166,5 +184,8 @@ describe('Profile', () => {
|
||||
|
||||
await waitFor(() => expect(fetchAccountStatuses).toHaveBeenCalledTimes(2))
|
||||
expect(lookupAccount).toHaveBeenCalledOnce()
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole('button', { name: 'Kudos (5)' })).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+25
-24
@@ -13,6 +13,8 @@
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte'
|
||||
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
|
||||
|
||||
const { session, theme } = useAppServices()
|
||||
|
||||
@@ -41,6 +43,7 @@
|
||||
['.module-body', 'Its contents'],
|
||||
['.module--band', 'The peach-bar variant used in the main column'],
|
||||
['.section-heading', '“About me:” style orange headings'],
|
||||
['.notification-toast-stack, .notification-toast', 'Background notification toasts'],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -87,7 +90,7 @@
|
||||
|
||||
<div class="page settings-page">
|
||||
<h1 class="page-title">Settings</h1>
|
||||
<p class="page-subtitle">Everything here is stored in this browser only.</p>
|
||||
<p class="page-subtitle">Edit your public profile and customise how plspace looks.</p>
|
||||
|
||||
<div class="layout--single">
|
||||
<Module title="Your account">
|
||||
@@ -116,6 +119,10 @@
|
||||
{/if}
|
||||
</Module>
|
||||
|
||||
<Module title="Your public profile" variant="band">
|
||||
<PublicProfileEditor />
|
||||
</Module>
|
||||
|
||||
<Module title="Pick a layout" variant="band">
|
||||
<ul class="preset-list">
|
||||
{#each PRESETS as preset (preset.id)}
|
||||
@@ -129,11 +136,15 @@
|
||||
</ul>
|
||||
</Module>
|
||||
|
||||
<Module title="Your CSS" variant="band">
|
||||
<Module title="CSS for this browser only" variant="band">
|
||||
<p class="notice">
|
||||
<strong>Private viewing setting:</strong>
|
||||
this changes how plspace looks only for you, in this browser. It does not update your
|
||||
profile, and nobody else can see it.
|
||||
</p>
|
||||
<p>
|
||||
This is applied to every page you view in plspace. Overriding the custom properties in
|
||||
<code>styles/tokens.css</code> retints the entire app; the class hooks below let you go
|
||||
further.
|
||||
Overriding the custom properties in <code>styles/tokens.css</code> retints the entire app;
|
||||
the class hooks below let you go further.
|
||||
</p>
|
||||
|
||||
<label class="visually-hidden" for="viewer-css">Your CSS</label>
|
||||
@@ -173,32 +184,22 @@
|
||||
</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
A profile can publish a stylesheet by putting CSS in a profile field named
|
||||
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}.
|
||||
plspace publishes layouts in ordered fields named <code>plspace:css1</code>,
|
||||
<code>plspace:css2</code>, and so on. Legacy
|
||||
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}
|
||||
fields are still understood.
|
||||
Their rules are rewritten to apply only inside <code>{PROFILE_SCOPE}</code>, so a profile
|
||||
can restyle its own page but not the rest of plspace.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Publish your own layout" variant="band">
|
||||
<Module title="CSS published on your profile" variant="band">
|
||||
<p>
|
||||
Add a profile field on <strong>{domain || 'your server'}</strong> named <code>css</code> and
|
||||
paste a stylesheet into its value. Anyone viewing your profile in plspace sees it. Since
|
||||
it's an ordinary profile field, it survives elsewhere too — other clients just show it
|
||||
as text.
|
||||
This editor writes the layout into profile fields on
|
||||
<strong>{domain || 'your server'}</strong>. Anyone who visits your profile with plspace
|
||||
will receive it automatically.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
<p>
|
||||
<a
|
||||
class="button"
|
||||
href={`https://${session.host}/settings/profile`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Edit your profile on {domain}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
<PublishedCssEditor />
|
||||
</Module>
|
||||
|
||||
<Module title="Class reference" variant="band">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { testServices } from '$test/fixtures'
|
||||
import Settings from './Settings.svelte'
|
||||
|
||||
describe('Settings CSS language', () => {
|
||||
it('distinguishes private viewer CSS from publicly published profile CSS', () => {
|
||||
const view = render(Settings, {
|
||||
context: new Map([[APP_SERVICES, testServices()]]),
|
||||
})
|
||||
|
||||
expect(
|
||||
view.getByRole('heading', { name: 'CSS for this browser only' }),
|
||||
).toBeInTheDocument()
|
||||
expect(view.getByText(/nobody else can see it/i)).toBeInTheDocument()
|
||||
expect(
|
||||
view.getByRole('heading', { name: 'CSS published on your profile' }),
|
||||
).toBeInTheDocument()
|
||||
expect(view.getByText(/anyone who visits your profile with plspace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,7 @@
|
||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import Avatar from '$components/common/Avatar.svelte'
|
||||
import RichText from '$components/common/RichText.svelte'
|
||||
import MfmContent from '$components/common/MfmContent.svelte'
|
||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||
import Composer from '$components/blog/Composer.svelte'
|
||||
|
||||
@@ -95,10 +95,7 @@
|
||||
const host = session.host
|
||||
const loaded = status !== null
|
||||
if (!timelineRefresh || !host || !loaded) return
|
||||
return timelineRefresh.register(() => {
|
||||
const generation = ++loadGeneration
|
||||
void load(currentId, host, generation)
|
||||
})
|
||||
return timelineRefresh.register(() => void refreshEntry(currentId, host))
|
||||
})
|
||||
|
||||
async function load(currentId: string, host: string, generation: number): Promise<void> {
|
||||
@@ -123,6 +120,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh the entry and complete thread snapshot without hiding the page. */
|
||||
async function refreshEntry(currentId: string, host: string): Promise<void> {
|
||||
const generation = ++loadGeneration
|
||||
try {
|
||||
const [entry, context] = await Promise.all([
|
||||
endpoints.fetchStatus(session.api, currentId),
|
||||
endpoints.fetchContext(session.api, currentId).catch(() => ({
|
||||
ancestors: [],
|
||||
descendants: [],
|
||||
})),
|
||||
])
|
||||
if (generation !== loadGeneration || id !== currentId || session.host !== host) return
|
||||
|
||||
status = entry
|
||||
ancestors = context.ancestors
|
||||
descendants = context.descendants
|
||||
error = null
|
||||
} catch (cause) {
|
||||
if (generation !== loadGeneration) return
|
||||
status = null
|
||||
ancestors = []
|
||||
descendants = []
|
||||
error = cause instanceof Error ? cause.message : 'That entry was deleted or is no longer available.'
|
||||
}
|
||||
}
|
||||
|
||||
function onPosted(created: Status): void {
|
||||
descendants = [...descendants, created]
|
||||
if (status) status = { ...status, replies_count: status.replies_count + 1 }
|
||||
@@ -206,7 +229,7 @@
|
||||
{#if reply.status.spoiler_text}
|
||||
<details class="content-warning">
|
||||
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
|
||||
<RichText
|
||||
<MfmContent
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
mentions={reply.status.mentions}
|
||||
@@ -214,7 +237,7 @@
|
||||
/>
|
||||
</details>
|
||||
{:else}
|
||||
<RichText
|
||||
<MfmContent
|
||||
html={reply.status.content}
|
||||
emojis={reply.status.emojis}
|
||||
mentions={reply.status.mentions}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => timelineRefresh?.register(() => void feed.reload()))
|
||||
$effect(() => timelineRefresh?.register(() => void feed.refresh()))
|
||||
</script>
|
||||
|
||||
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
||||
|
||||
@@ -285,6 +285,46 @@
|
||||
background: var(--ms-nav-bg);
|
||||
}
|
||||
|
||||
.poll-option[data-own-vote='true'] .poll-option-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.poll-own-vote {
|
||||
color: var(--ms-link-fg);
|
||||
}
|
||||
|
||||
.poll-choices {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poll-choice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.poll-choice input {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.poll-vote-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.poll-sign-in,
|
||||
.poll-notice {
|
||||
margin: 6px 0 0;
|
||||
font-size: var(--ms-font-size-small);
|
||||
}
|
||||
|
||||
.poll-meta {
|
||||
font-size: var(--ms-font-size-small);
|
||||
color: var(--ms-muted-fg);
|
||||
|
||||
@@ -236,6 +236,75 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- notification toasts */
|
||||
|
||||
.notification-toast-stack {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: min(310px, calc(100vw - 16px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
padding: 7px;
|
||||
color: var(--ms-page-fg);
|
||||
background: var(--ms-module-bg);
|
||||
border: 1px solid var(--ms-input-border);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notification-toast:visited {
|
||||
color: var(--ms-page-fg);
|
||||
}
|
||||
|
||||
.notification-toast:hover,
|
||||
.notification-toast:focus-visible {
|
||||
color: var(--ms-page-fg);
|
||||
background: var(--ms-table-stripe-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notification-toast-avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.notification-toast-body,
|
||||
.notification-toast-message,
|
||||
.notification-toast-excerpt {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-toast-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-toast-message {
|
||||
font-size: var(--ms-font-size);
|
||||
}
|
||||
|
||||
.notification-toast-excerpt {
|
||||
margin-top: 2px;
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.site-header-inner {
|
||||
gap: 6px;
|
||||
@@ -257,4 +326,10 @@
|
||||
.site-account-links {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.notification-toast-stack {
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: calc(100vw - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ input[type='search'],
|
||||
input[type='url'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='date'],
|
||||
select,
|
||||
textarea {
|
||||
padding: 2px 4px;
|
||||
@@ -171,6 +172,159 @@ textarea {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.composer-poll {
|
||||
margin: 6px 0 0;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--ms-module-border);
|
||||
}
|
||||
|
||||
.composer-poll legend {
|
||||
padding: 0 4px;
|
||||
color: var(--ms-heading-fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.composer-poll-options {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.composer-poll-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.composer-poll-option .field-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.composer-poll-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- profile editor */
|
||||
|
||||
.profile-editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-editor fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--ms-module-border);
|
||||
}
|
||||
|
||||
.profile-editor legend {
|
||||
padding: 0 4px;
|
||||
color: var(--ms-heading-fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-editor-bio {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.profile-editor-images {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-editor-image {
|
||||
display: grid;
|
||||
grid-template-columns: 90px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-editor-image > img,
|
||||
.profile-editor-image-placeholder {
|
||||
display: block;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border: 1px solid var(--ms-avatar-border);
|
||||
background: var(--ms-table-stripe-bg);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-editor-image--wide > img,
|
||||
.profile-editor-image--wide > .profile-editor-image-placeholder {
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.profile-editor-image-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ms-muted-fg);
|
||||
font-size: var(--ms-font-size-small);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-editor-image > div {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-editor-image > div > strong {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.profile-editor-description {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.profile-editor-fields {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-editor-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 1fr) minmax(160px, 2fr) auto;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-editor-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-editor-identity .field {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.profile-editor-image {
|
||||
grid-template-columns: 70px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.profile-editor-image > img,
|
||||
.profile-editor-image-placeholder {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.profile-editor-image--wide > img,
|
||||
.profile-editor-image--wide > .profile-editor-image-placeholder {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.profile-editor-field {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.profile-editor-field label:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* The CSS editor in Settings wants to be a code surface, not prose. */
|
||||
.css-editor {
|
||||
width: 100%;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
@import './tokens.css';
|
||||
@import './base.css';
|
||||
@import './mfm.css';
|
||||
@import './layout.css';
|
||||
@import './chrome.css';
|
||||
@import './module.css';
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* MFM presentation ported from Pleroma-FE RichContent at
|
||||
* 38317bee99fbc9d680334eae5dcc33a37bb9777f.
|
||||
*/
|
||||
|
||||
.rich-text {
|
||||
--emoji-size: var(--ms-emoji-size);
|
||||
}
|
||||
|
||||
.rich-text .mfm {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rich-text .mfm.-scale {
|
||||
font-size: calc(var(--emoji-size) / 2);
|
||||
}
|
||||
|
||||
.rich-text .mfm:not(.-scale) {
|
||||
--emoji-size: 2em;
|
||||
}
|
||||
|
||||
.rich-text .mfm.-pause {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='x2'] {
|
||||
font-size: 300%;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='x3'] {
|
||||
font-size: 400%;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='x4'] {
|
||||
font-size: 600%;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='sparkle'] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='sparkle']::before,
|
||||
.rich-text .mfm[data-mfm-operator='sparkle']::after {
|
||||
content: '✨';
|
||||
position: absolute;
|
||||
opacity: 0.4;
|
||||
z-index: -1;
|
||||
animation-name: cheap-sparkle;
|
||||
animation-duration: 1s;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='sparkle']::before {
|
||||
left: 0;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
.rich-text .mfm[data-mfm-operator='sparkle']::after {
|
||||
right: 0;
|
||||
animation-direction: alternate-reverse;
|
||||
}
|
||||
|
||||
.rich-text .mfm .custom-emoji {
|
||||
/* Misskey's emoji width knows no bounds. */
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
||||
.rich-text:hover .mfm {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
@keyframes mfm-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-spinX {
|
||||
0% {
|
||||
transform: perspective(8em) rotateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: perspective(8em) rotateX(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-spinY {
|
||||
0% {
|
||||
transform: perspective(8em) rotateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: perspective(8em) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-jump {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateY(-1em);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(-0.5em);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-bounce {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateY(-16px) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(0) scale(1.5, 0.75);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-twitch {
|
||||
0% {
|
||||
transform: translate(7px, -2px);
|
||||
}
|
||||
|
||||
5% {
|
||||
transform: translate(-3px, 1px);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(-7px, -1px);
|
||||
}
|
||||
|
||||
15% {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-8px, 6px);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(-4px, -3px);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-4px, -6px);
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: translate(-8px, -8px);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(4px, 6px);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: translate(-3px, 1px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(2px, -10px);
|
||||
}
|
||||
|
||||
55% {
|
||||
transform: translate(-7px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(-2px, 4px);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: translate(3px, -8px);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(6px, 7px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(-7px, -2px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(-7px, -8px);
|
||||
}
|
||||
|
||||
85% {
|
||||
transform: translate(9px, 3px);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(-3px, -2px);
|
||||
}
|
||||
|
||||
95% {
|
||||
transform: translate(-10px, 2px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-2px, -6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-shake {
|
||||
0% {
|
||||
transform: translate(-3px, -1px) rotate(-8deg);
|
||||
}
|
||||
|
||||
5% {
|
||||
transform: translateY(-1px) rotate(-10deg);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(1px, -3px) rotate(0);
|
||||
}
|
||||
|
||||
15% {
|
||||
transform: translate(1px, 1px) rotate(11deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-2px, 1px) rotate(1deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(-1px, -2px) rotate(-2deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-1px, 2px) rotate(-3deg);
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: translate(2px, 1px) rotate(6deg);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(-2px, -3px) rotate(-9deg);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: translateY(-1px) rotate(-12deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(1px, 2px) rotate(10deg);
|
||||
}
|
||||
|
||||
55% {
|
||||
transform: translateY(-3px) rotate(8deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(1px, -1px) rotate(8deg);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: translateY(-1px) rotate(-7deg);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(-1px, -3px) rotate(6deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateY(-2px) rotate(4deg);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(-2px, -1px) rotate(3deg);
|
||||
}
|
||||
|
||||
85% {
|
||||
transform: translate(1px, -3px) rotate(-10deg);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(1px) rotate(3deg);
|
||||
}
|
||||
|
||||
95% {
|
||||
transform: translate(-2px) rotate(-3deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(2px, 1px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-rubberBand {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: scale(1.25, 0.75);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(0.75, 1.25);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.15, 0.85);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: scale(0.95, 1.05);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scale(1.05, 0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mfm-rainbow {
|
||||
0% {
|
||||
filter: hue-rotate() contrast(150%) saturate(150%);
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: hue-rotate(360deg) contrast(150%) saturate(150%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cheap-sparkle {
|
||||
0% {
|
||||
filter: hue-rotate() contrast(150%) saturate(150%);
|
||||
transform: translateY(-0.5em);
|
||||
}
|
||||
|
||||
100% {
|
||||
filter: hue-rotate(360deg) contrast(150%) saturate(150%);
|
||||
transform: translateY(0.5em);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user