Full source

This commit is contained in:
0% [█ █ █ █ █ █ █ █ █ █] 100% 2026-06-14 21:28:04 -05:00
parent 2767297b86
commit 29e61f07f2
65 changed files with 10215 additions and 3 deletions

231
LICENSE Normal file
View File

@ -0,0 +1,231 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is 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. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
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.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
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 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. Use with the GNU Affero General Public License.
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 Affero 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 special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU 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 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 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 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 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 General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
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 GPL, see <http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.

View File

@ -0,0 +1,9 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
</Configurations>
<Project Path="PeercordInstaller/PeercordInstaller.csproj">
<Platform Solution="*|x64" Project="x64" />
</Project>
</Solution>

View File

@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PeercordInstaller.App"
RequestedThemeVariant="Dark">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace PeercordInstaller
{
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -0,0 +1,293 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace PeercordInstaller.Installers
{
public class Linux
{
// TODO: Replace this with the direct link to your Peercord Linux .zip release
private const string DOWNLOAD_URL = "https://storage.mastercodeon.dev/Peercord%20Release/peercord-linux-x64.zip";
public void AppendLog(string text) => Utils.Instance.AppendLog(text);
public static string GetInstallPath()
{
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peercord", "install_path.txt");
if (File.Exists(path))
{
return File.ReadAllText(path).Trim();
}
}
catch { }
return null;
}
public static void SetInstallPath(string targetDir)
{
try
{
string dirPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peercord");
Directory.CreateDirectory(dirPath);
File.WriteAllText(Path.Combine(dirPath, "install_path.txt"), targetDir);
}
catch { }
}
public static void RemoveInstallPath()
{
try
{
string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peercord", "install_path.txt");
if (File.Exists(path))
{
File.Delete(path);
}
}
catch { }
}
public async Task<string> InstallPeercordLinuxAsync(string targetDir)
{
Utils.Instance.SetProgress(10, "Downloading Peercord...");
string zipPath = Path.Combine(Path.GetTempPath(), "Peercord.zip");
await DownloadFileAsync(DOWNLOAD_URL, zipPath);
Utils.Instance.SetProgress(70, "Extracting files...");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Extracting files to {targetDir}...\n");
if (!Directory.Exists(targetDir))
{
Directory.CreateDirectory(targetDir);
}
// Extract zip, handling Windows-style backslashes in zip entries on Linux
using (ZipArchive archive = ZipFile.OpenRead(zipPath))
{
int totalFiles = archive.Entries.Count;
int extracted = 0;
foreach (ZipArchiveEntry entry in archive.Entries)
{
// Replace backslashes with forward slashes for Linux compatibility
string fixedName = entry.FullName.Replace('\\', '/');
string destinationPath = Path.GetFullPath(Path.Combine(targetDir, fixedName));
// Prevent ZipSlip vulnerability
if (!destinationPath.StartsWith(Path.GetFullPath(targetDir), StringComparison.Ordinal))
continue;
if (fixedName.EndsWith("/"))
{
// It's a directory
Directory.CreateDirectory(destinationPath);
}
else
{
// It's a file, ensure its parent directory exists
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
entry.ExtractToFile(destinationPath, true);
}
extracted++;
if (extracted % 50 == 0 || extracted == totalFiles)
{
int percent = 70 + (int)((extracted / (double)totalFiles) * 10); // 70 to 80
Utils.Instance.SetProgress(percent, "Extracting files...");
AppendLog($"\r[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [PROGRESS] Extracting... {extracted}/{totalFiles} files ({(extracted / (double)totalFiles):P0})");
}
}
}
AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Extraction complete.\n");
File.Delete(zipPath);
// The executable is typically named after the package name in lowercase
string exePath = Path.Combine(targetDir, "peercord");
// Fallback in case it was capitalized by the build process
if (!File.Exists(exePath) && File.Exists(Path.Combine(targetDir, "Peercord")))
{
exePath = Path.Combine(targetDir, "Peercord");
}
Utils.Instance.SetProgress(80, "Configuring permissions...");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Configuring execute permissions...\n");
// Recursively add execute permissions to the directory so internal Electron binaries (like chrome-sandbox) work
await Utils.RunCommandAsync("chmod", $"-R +x \"{targetDir}\"");
// Save installation record
SetInstallPath(targetDir);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Peercord installed successfully.\n");
return exePath;
}
public async Task UninstallPeercordLinuxAsync(string targetDir)
{
Utils.Instance.SetProgress(10, "Preparing uninstallation...");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Starting uninstallation from {targetDir}...\n");
Utils.Instance.SetProgress(30, "Removing shortcuts...");
string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (string.IsNullOrEmpty(desktopPath) || !Directory.Exists(desktopPath))
{
desktopPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Desktop");
}
string desktopShortcut = Path.Combine(desktopPath, "peercord.desktop");
if (File.Exists(desktopShortcut))
{
File.Delete(desktopShortcut);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed Desktop shortcut.\n");
}
string appMenuPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "applications", "peercord.desktop");
if (File.Exists(appMenuPath))
{
File.Delete(appMenuPath);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed App Menu shortcut.\n");
}
Utils.Instance.SetProgress(50, "Removing files...");
if (Directory.Exists(targetDir))
{
Directory.Delete(targetDir, true);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed installation directory.\n");
}
Utils.Instance.SetProgress(90, "Cleaning up system records...");
RemoveInstallPath();
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed installation records.\n");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Uninstallation complete.\n");
Utils.Instance.SetProgress(100, "Done!");
}
public async Task CreateAppMenuShortcut(string targetExe, string targetDir)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Creating App Menu shortcut...\n");
string applicationsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "applications");
Directory.CreateDirectory(applicationsPath);
string iconPath = Path.Combine(targetDir, "resources", "app", "assets", "icon.png");
string desktopEntry = $@"[Desktop Entry]
Name=Peercord
Exec=""{targetExe}""
Icon={iconPath}
Type=Application
Terminal=false
Categories=Network;
";
string shortcutName = "peercord.desktop";
string appMenuPath = Path.Combine(applicationsPath, shortcutName);
await File.WriteAllTextAsync(appMenuPath, desktopEntry);
await Utils.RunCommandAsync("chmod", $"+x \"{appMenuPath}\"");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] App Menu shortcut created.\n");
}
public async Task CreateDesktopShortcut(string targetExe, string targetDir)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Creating Desktop shortcut...\n");
string iconPath = Path.Combine(targetDir, "resources", "app", "assets", "icon.png");
string desktopEntry = $@"[Desktop Entry]
Name=Peercord
Exec=""{targetExe}""
Icon={iconPath}
Type=Application
Terminal=false
Categories=Network;
";
string shortcutName = "peercord.desktop";
string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (string.IsNullOrEmpty(desktopPath) || !Directory.Exists(desktopPath))
{
desktopPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Desktop");
}
if (Directory.Exists(desktopPath))
{
string desktopShortcutPath = Path.Combine(desktopPath, shortcutName);
await File.WriteAllTextAsync(desktopShortcutPath, desktopEntry);
// Make it executable
await Utils.RunCommandAsync("chmod", $"+x \"{desktopShortcutPath}\"");
// Mark it as trusted for environments like GNOME
await Utils.RunCommandAsync("gio", $"set \"{desktopShortcutPath}\" metadata::trusted true");
// Mark it as trusted for XFCE (Kali Linux default)
try
{
byte[] fileBytes = await File.ReadAllBytesAsync(desktopShortcutPath);
using (SHA256 sha256 = SHA256.Create())
{
byte[] hashBytes = sha256.ComputeHash(fileBytes);
string hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
await Utils.RunCommandAsync("gio", $"set -t string \"{desktopShortcutPath}\" metadata::xfce-exe-checksum \"{hashString}\"");
}
}
catch (Exception ex)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] Failed to set XFCE trust metadata: {ex.Message}\n");
}
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Desktop shortcut created and marked as trusted.\n");
}
}
private async Task DownloadFileAsync(string url, string destination)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Downloading Peercord from {url}...\n");
using var client = new HttpClient();
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
using var contentStream = await response.Content.ReadAsStreamAsync();
using var fileStream = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
var buffer = new byte[8192];
long totalRead = 0;
int bytesRead;
var stopwatch = Stopwatch.StartNew();
long lastReportTime = 0;
while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalRead += bytesRead;
if (stopwatch.ElapsedMilliseconds - lastReportTime > 500 || totalRead == totalBytes)
{
lastReportTime = stopwatch.ElapsedMilliseconds;
double speedMb = (totalRead / 1024.0 / 1024.0) / (stopwatch.ElapsedMilliseconds / 1000.0);
if (totalBytes.HasValue)
{
double percentage = (double)totalRead / totalBytes.Value * 100;
AppendLog($"\r[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [PROGRESS] Downloading... {percentage:F1}% ({speedMb:F2} MB/s)");
}
else
{
AppendLog($"\r[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [PROGRESS] Downloading... {totalRead / 1024.0 / 1024.0:F2} MB ({speedMb:F2} MB/s)");
}
}
}
AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Download complete.\n");
}
}
}

View File

@ -0,0 +1,118 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace PeercordInstaller.Installers
{
public class Utils
{
public static Utils Instance = null;
private Action<string> appendLogFunc;
private Action<int, string> setProgressFunc;
public void AppendLog(string text) => appendLogFunc.Invoke(text);
public void SetProgress(int value, string status) => setProgressFunc.Invoke(value, status);
public Utils(Action<string> logDel, Action<int, string> setProgDel)
{
appendLogFunc = logDel;
setProgressFunc = setProgDel;
}
public static async Task ReadStreamAsync(StreamReader reader)
{
char[] buffer = new char[1024];
int bytesRead;
while ((bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
string text = new string(buffer, 0, bytesRead);
text = Regex.Replace(text, @"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "");
Utils.Instance.AppendLog(text);
}
}
public static async Task RunCommandAsync(string fileName, string arguments, string? inputText = null, int timeoutMs = -1)
{
Utils.Instance.AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [EXEC] {fileName} {arguments}\n");
var startInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = inputText != null,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
process.Start();
var outTask = ReadStreamAsync(process.StandardOutput);
var errTask = ReadStreamAsync(process.StandardError);
if (inputText != null)
{
try
{
await process.StandardInput.WriteLineAsync(inputText);
await process.StandardInput.FlushAsync();
process.StandardInput.Close();
}
catch { }
}
if (timeoutMs > 0)
{
using var cts = new System.Threading.CancellationTokenSource(timeoutMs);
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (TaskCanceledException)
{
Utils.Instance.AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] Command reached timeout. Terminating background process to continue...\n");
try { process.Kill(true); } catch { }
}
}
else
{
await process.WaitForExitAsync();
}
await Task.WhenAll(outTask, errTask);
int exitCode = -1;
try { exitCode = process.ExitCode; } catch { }
Utils.Instance.AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [EXEC] Command finished with exit code {exitCode}\n");
}
public static void LaunchPeercord(string targetExe)
{
try
{
if (File.Exists(targetExe))
{
Process.Start(new ProcessStartInfo
{
FileName = targetExe,
UseShellExecute = true
});
}
else
{
Console.WriteLine("Could not find installed Peercord executable to launch.");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to launch Peercord: {ex.Message}");
}
}
}
}

View File

@ -0,0 +1,236 @@
#pragma warning disable CA1416 // Validate platform compatibility
using Microsoft.Win32;
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Threading.Tasks;
namespace PeercordInstaller.Installers
{
public class Windows
{
// TODO: Replace this with the direct link to your Peercord Windows .zip release
private const string DOWNLOAD_URL = "https://storage.mastercodeon.dev/Peercord%20Release/peercord-win32-x64.zip";
public void AppendLog(string text) => Utils.Instance.AppendLog(text);
public static string GetInstallPath()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Peercord");
return key?.GetValue("InstallPath") as string;
}
catch
{
return null;
}
}
public static void SetInstallPath(string path)
{
try
{
using var key = Registry.CurrentUser.CreateSubKey(@"Software\Peercord");
key.SetValue("InstallPath", path);
}
catch { }
}
public static void RemoveInstallPath()
{
try
{
Registry.CurrentUser.DeleteSubKeyTree(@"Software\Peercord", false);
}
catch { }
}
public async Task<string> InstallPeercordWindowsAsync(string targetDir)
{
Utils.Instance.SetProgress(10, "Downloading Peercord...");
string zipPath = Path.Combine(Path.GetTempPath(), "Peercord.zip");
await DownloadFileAsync(DOWNLOAD_URL, zipPath);
Utils.Instance.SetProgress(70, "Extracting files...");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Extracting files to {targetDir}...\n");
if (!Directory.Exists(targetDir))
{
Directory.CreateDirectory(targetDir);
}
using (ZipArchive archive = ZipFile.OpenRead(zipPath))
{
int totalFiles = archive.Entries.Count;
int extracted = 0;
foreach (ZipArchiveEntry entry in archive.Entries)
{
string destinationPath = Path.GetFullPath(Path.Combine(targetDir, entry.FullName));
// Prevent ZipSlip vulnerability
if (!destinationPath.StartsWith(Path.GetFullPath(targetDir), StringComparison.Ordinal))
continue;
if (entry.FullName.EndsWith("/") || entry.FullName.EndsWith("\\"))
{
Directory.CreateDirectory(destinationPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
entry.ExtractToFile(destinationPath, true);
}
extracted++;
if (extracted % 50 == 0 || extracted == totalFiles)
{
int percent = 70 + (int)((extracted / (double)totalFiles) * 20); // 70 to 90
Utils.Instance.SetProgress(percent, "Extracting files...");
AppendLog($"\r[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [PROGRESS] Extracting... {extracted}/{totalFiles} files ({(extracted / (double)totalFiles):P0})");
}
}
}
AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Extraction complete.\n");
File.Delete(zipPath);
string exePath = Path.Combine(targetDir, "Peercord.exe");
// Save installation record
SetInstallPath(targetDir);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Peercord installed successfully.\n");
return exePath;
}
public async Task UninstallPeercordWindowsAsync(string targetDir)
{
Utils.Instance.SetProgress(10, "Preparing uninstallation...");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Starting uninstallation from {targetDir}...\n");
Utils.Instance.SetProgress(30, "Removing shortcuts...");
string desktopPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "Peercord.lnk");
if (File.Exists(desktopPath))
{
File.Delete(desktopPath);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed Desktop shortcut.\n");
}
string startMenuPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", "Peercord.lnk");
if (File.Exists(startMenuPath))
{
File.Delete(startMenuPath);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed Start Menu shortcut.\n");
}
Utils.Instance.SetProgress(50, "Removing files...");
if (Directory.Exists(targetDir))
{
Directory.Delete(targetDir, true);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed installation directory.\n");
}
Utils.Instance.SetProgress(90, "Cleaning up registry...");
RemoveInstallPath();
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Removed registry records.\n");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Uninstallation complete.\n");
Utils.Instance.SetProgress(100, "Done!");
}
public void CreateStartMenuShortcut(string targetDir, string exePath)
{
try
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Creating Start Menu shortcut...\n");
IShellLinkW link = (IShellLinkW)new ShellLink();
link.SetPath(exePath);
link.SetWorkingDirectory(targetDir);
link.SetDescription("Peercord P2P Client");
IPersistFile file = (IPersistFile)link;
string startMenuPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs");
Directory.CreateDirectory(startMenuPath);
string startMenuShortcutPath = Path.Combine(startMenuPath, "Peercord.lnk");
file.Save(startMenuShortcutPath, false);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Start Menu shortcut created.\n");
}
catch (Exception ex)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] Failed to create Start Menu shortcut: {ex.Message}\n");
}
}
public void CreateDesktopShortcut(string targetDir, string exePath)
{
try
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Creating Desktop shortcut...\n");
string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
string desktopShortcutPath = Path.Combine(desktopPath, "Peercord.lnk");
IShellLinkW link = (IShellLinkW)new ShellLink();
link.SetPath(exePath);
link.SetWorkingDirectory(targetDir);
link.SetDescription("Peercord P2P Client");
IPersistFile file = (IPersistFile)link;
file.Save(desktopShortcutPath, false);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Desktop shortcut created.\n");
}
catch (Exception ex)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] Failed to create Desktop shortcut: {ex.Message}\n");
}
}
private async Task DownloadFileAsync(string url, string destination)
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Downloading Peercord from {url}...\n");
using var client = new HttpClient();
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
using var contentStream = await response.Content.ReadAsStreamAsync();
using var fileStream = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
var buffer = new byte[8192];
long totalRead = 0;
int bytesRead;
var stopwatch = Stopwatch.StartNew();
long lastReportTime = 0;
while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead);
totalRead += bytesRead;
if (stopwatch.ElapsedMilliseconds - lastReportTime > 500 || totalRead == totalBytes)
{
lastReportTime = stopwatch.ElapsedMilliseconds;
double speedMb = (totalRead / 1024.0 / 1024.0) / (stopwatch.ElapsedMilliseconds / 1000.0);
if (totalBytes.HasValue)
{
double percentage = (double)totalRead / totalBytes.Value * 100;
AppendLog($"\r[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [PROGRESS] Downloading... {percentage:F1}% ({speedMb:F2} MB/s)");
}
else
{
AppendLog($"\r[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [PROGRESS] Downloading... {totalRead / 1024.0 / 1024.0:F2} MB ({speedMb:F2} MB/s)");
}
}
}
AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Download complete.\n");
}
}
}

View File

@ -0,0 +1,78 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PeercordInstaller.MainWindow"
Title="Peercord Setup" Width="650" Height="450"
WindowStartupLocation="CenterScreen"
RequestedThemeVariant="Dark"
Icon="Assets/app.ico"
Background="#121212">
<Grid>
<!-- Installer UI -->
<Grid x:Name="InstallerUI" RowDefinitions="*, Auto">
<!-- Content Area -->
<Border Grid.Row="0" Background="#1e1e1e" Margin="20" CornerRadius="8" Padding="20">
<Grid>
<!-- Step 0: Welcome -->
<StackPanel x:Name="WelcomePage" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="Welcome to the Peercord Setup Wizard" FontSize="26" FontWeight="SemiBold" Foreground="#ffffff" Margin="0,0,0,15" TextAlignment="Center"/>
<TextBlock Text="This wizard will guide you through the installation of Peercord." FontSize="14" Foreground="#cccccc" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="450"/>
<TextBlock Text="Click Next to continue." FontSize="14" Foreground="#cccccc" Margin="0,20,0,0" TextAlignment="Center"/>
</StackPanel>
<!-- Step 1: Location -->
<StackPanel x:Name="LocationPage" IsVisible="False" VerticalAlignment="Center" HorizontalAlignment="Stretch">
<TextBlock Text="Choose Installation Location" FontSize="26" FontWeight="SemiBold" Foreground="#ffffff" Margin="0,0,0,15" TextAlignment="Center"/>
<TextBlock Text="Select the folder where Peercord will be installed:" FontSize="14" Foreground="#cccccc" Margin="0,0,0,10"/>
<Grid ColumnDefinitions="*, Auto">
<TextBox x:Name="InstallLocationBox" Grid.Column="0" Margin="0,0,10,0" VerticalAlignment="Center"/>
<Button x:Name="BrowseButton" Grid.Column="1" Content="Browse..." Click="BrowseButton_Click" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
<!-- Step 2 & 11: Install / Uninstall Progress -->
<Grid x:Name="InstallPage" IsVisible="False" RowDefinitions="Auto, *, Auto">
<TextBlock x:Name="StatusText" Text="Preparing..." FontSize="16" FontWeight="SemiBold" Foreground="#ffffff" Margin="0,0,0,10"/>
<Border Grid.Row="1" Background="#000000" CornerRadius="4" BorderBrush="#333333" BorderThickness="1">
<ScrollViewer x:Name="LogScrollViewer" Margin="5" HorizontalScrollBarVisibility="Auto">
<SelectableTextBlock x:Name="LogText" FontFamily="Consolas, Courier New, Monospace" FontSize="12" Foreground="#00ff00" TextWrapping="NoWrap"/>
</ScrollViewer>
</Border>
<ProgressBar x:Name="InstallProgress" Grid.Row="2" Height="15" Margin="0,15,0,0" Minimum="0" Maximum="100" Foreground="#0078d7"/>
</Grid>
<!-- Step 3: Finish -->
<StackPanel x:Name="FinishPage" IsVisible="False" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="Installation Complete" FontSize="26" FontWeight="SemiBold" Foreground="#ffffff" Margin="0,0,0,15" TextAlignment="Center"/>
<TextBlock Text="Peercord has been successfully installed." FontSize="14" Foreground="#cccccc" TextWrapping="Wrap" TextAlignment="Center"/>
<TextBlock Text="You can now launch it from your desktop shortcut." FontSize="14" Foreground="#cccccc" Margin="0,5,0,0" TextAlignment="Center"/>
<CheckBox x:Name="DesktopShortcutCheckBox" Content="Create Desktop Shortcut" IsChecked="True" Foreground="#ffffff" Margin="0,20,0,0" HorizontalAlignment="Center"/>
<CheckBox x:Name="MenuShortcutCheckBox" Content="Create Menu Shortcut" IsChecked="True" Foreground="#ffffff" Margin="0,10,0,0" HorizontalAlignment="Center"/>
<CheckBox x:Name="LaunchCheckBox" Content="Launch Peercord on exit" IsChecked="True" Foreground="#ffffff" Margin="0,10,0,0" HorizontalAlignment="Center"/>
</StackPanel>
<!-- Step 10: Uninstall Welcome -->
<StackPanel x:Name="UninstallWelcomePage" IsVisible="False" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="Peercord is already installed" FontSize="26" FontWeight="SemiBold" Foreground="#ffffff" Margin="0,0,0,15" TextAlignment="Center"/>
<TextBlock Text="An existing installation of Peercord was detected on your system." FontSize="14" Foreground="#cccccc" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="450"/>
<TextBlock Text="Would you like to uninstall it?" FontSize="14" Foreground="#cccccc" Margin="0,20,0,0" TextAlignment="Center"/>
</StackPanel>
<!-- Step 12: Uninstall Finish -->
<StackPanel x:Name="UninstallFinishPage" IsVisible="False" VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="Uninstallation Complete" FontSize="26" FontWeight="SemiBold" Foreground="#ffffff" Margin="0,0,0,15" TextAlignment="Center"/>
<TextBlock Text="Peercord has been successfully removed from your computer." FontSize="14" Foreground="#cccccc" TextWrapping="Wrap" TextAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<!-- Navigation Area -->
<Border Grid.Row="1" Background="#1e1e1e" Padding="20,10">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="NextButton" Content="Next" Width="100" HorizontalContentAlignment="Center" Click="NextButton_Click" Background="#0078d7" Foreground="White"/>
<Button x:Name="CancelButton" Content="Cancel" Width="100" HorizontalContentAlignment="Center" Margin="10,0,0,0" Click="CancelButton_Click"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,339 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using PeercordInstaller.Installers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
namespace PeercordInstaller;
public partial class MainWindow : Window
{
private int _currentStep = 0;
private readonly object _logLock = new object();
private bool _lastCharWasCr = false;
private string _currentLine = "";
private List<string> _logLines = new List<string>();
private string _installedExePath = "";
private string _existingInstallPath = "";
private bool _isBusy = false;
Installers.Windows windowsInstaller;
Installers.Linux linuxInstaller;
public MainWindow()
{
InitializeComponent();
Utils.Instance = new Utils(AppendLog, SetProgress);
// Set default installation paths and OS specific UI
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
InstallLocationBox.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "peercord");
MenuShortcutCheckBox.Content = "Create Start Menu Shortcut";
_existingInstallPath = Installers.Windows.GetInstallPath();
}
else
{
InstallLocationBox.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "peercord");
MenuShortcutCheckBox.Content = "Create App Menu Shortcut";
_existingInstallPath = Installers.Linux.GetInstallPath();
}
// Check if already installed
if (!string.IsNullOrEmpty(_existingInstallPath) && Directory.Exists(_existingInstallPath))
{
WelcomePage.IsVisible = false;
UninstallWelcomePage.IsVisible = true;
NextButton.Content = "Uninstall";
_currentStep = 10;
}
}
protected override void OnClosing(WindowClosingEventArgs e)
{
if (_isBusy)
{
e.Cancel = true;
}
base.OnClosing(e);
}
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
{
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select Installation Folder",
AllowMultiple = false
});
if (result != null && result.Count > 0)
{
InstallLocationBox.Text = Path.Combine(result[0].Path.LocalPath, "peercord");
}
}
private async void NextButton_Click(object sender, RoutedEventArgs e)
{
if (_currentStep == 0)
{
// Welcome -> Location
WelcomePage.IsVisible = false;
LocationPage.IsVisible = true;
_currentStep = 1;
}
else if (_currentStep == 1)
{
// Location -> Install
string targetDir = InstallLocationBox.Text;
if (string.IsNullOrWhiteSpace(targetDir)) return;
LocationPage.IsVisible = false;
InstallPage.IsVisible = true;
NextButton.IsEnabled = false;
CancelButton.IsEnabled = false;
_currentStep = 2;
_isBusy = true;
await Task.Run(async () => await RunInstallationAsync(targetDir));
// Installation finished (success). Stay on Install page to let user read logs.
Dispatcher.UIThread.Post(() =>
{
_isBusy = false;
NextButton.Content = "Next";
NextButton.IsEnabled = true;
CancelButton.IsEnabled = true;
_currentStep = 3;
});
}
else if (_currentStep == 3)
{
// Install -> Finish
InstallPage.IsVisible = false;
FinishPage.IsVisible = true;
NextButton.Content = "Finish";
_currentStep = 4;
}
else if (_currentStep == 4)
{
// Finish clicked
string targetDir = InstallLocationBox.Text;
if (DesktopShortcutCheckBox.IsChecked == true && !string.IsNullOrEmpty(_installedExePath))
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
windowsInstaller?.CreateDesktopShortcut(targetDir, _installedExePath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
if (linuxInstaller != null)
{
await linuxInstaller.CreateDesktopShortcut(_installedExePath, targetDir);
}
}
}
if (MenuShortcutCheckBox.IsChecked == true && !string.IsNullOrEmpty(_installedExePath))
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
windowsInstaller?.CreateStartMenuShortcut(targetDir, _installedExePath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
if (linuxInstaller != null)
{
await linuxInstaller.CreateAppMenuShortcut(_installedExePath, targetDir);
}
}
}
if (LaunchCheckBox.IsChecked == true && !string.IsNullOrEmpty(_installedExePath))
{
Utils.LaunchPeercord(_installedExePath);
}
Close();
}
else if (_currentStep == 5)
{
// Failed state, just close
Close();
}
else if (_currentStep == 10)
{
// Uninstall Welcome -> Uninstall Progress
UninstallWelcomePage.IsVisible = false;
InstallPage.IsVisible = true;
NextButton.IsEnabled = false;
CancelButton.IsEnabled = false;
_currentStep = 11;
_isBusy = true;
await Task.Run(async () => await RunUninstallAsync(_existingInstallPath));
Dispatcher.UIThread.Post(() =>
{
_isBusy = false;
NextButton.Content = "Next";
NextButton.IsEnabled = true;
CancelButton.IsEnabled = true;
_currentStep = 12;
});
}
else if (_currentStep == 12)
{
// Uninstall Progress -> Uninstall Finish
InstallPage.IsVisible = false;
UninstallFinishPage.IsVisible = true;
NextButton.Content = "Finish";
_currentStep = 13;
}
else if (_currentStep == 13)
{
// Uninstall Finish clicked
Close();
}
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
Close();
}
public void AppendLog(string text)
{
lock (_logLock)
{
foreach (char c in text)
{
if (c == '\n')
{
_logLines.Add(_currentLine);
_currentLine = "";
_lastCharWasCr = false;
}
else if (c == '\r')
{
_lastCharWasCr = true;
}
else
{
if (_lastCharWasCr)
{
_currentLine = ""; // overwrite current line
_lastCharWasCr = false;
}
_currentLine += c;
}
}
if (_logLines.Count > 1000)
{
_logLines.RemoveRange(0, _logLines.Count - 1000);
}
string display = string.Join(Environment.NewLine, _logLines);
if (!string.IsNullOrEmpty(_currentLine))
{
if (display.Length > 0) display += Environment.NewLine;
display += _currentLine;
}
Dispatcher.UIThread.Post(() =>
{
LogText.Text = display;
LogScrollViewer.ScrollToEnd();
});
}
}
private void SetProgress(int value, string status)
{
Dispatcher.UIThread.Post(() =>
{
InstallProgress.Value = value;
StatusText.Text = status;
});
}
private async Task RunInstallationAsync(string targetDir)
{
try
{
bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Starting installation process...\n");
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [INFO] Target directory: {targetDir}\n");
if (isWindows)
{
windowsInstaller = new Installers.Windows();
_installedExePath = await windowsInstaller.InstallPeercordWindowsAsync(targetDir);
Utils.Instance.SetProgress(100, "Done!");
}
else if (isLinux)
{
linuxInstaller = new Installers.Linux();
_installedExePath = await linuxInstaller.InstallPeercordLinuxAsync(targetDir);
Utils.Instance.SetProgress(100, "Done!");
}
else
{
AppendLog($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] Unsupported Operating System.\n");
}
}
catch (Exception ex)
{
AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] {ex.Message}\n");
SetProgress(100, "Installation Failed.");
Dispatcher.UIThread.Post(() =>
{
_isBusy = false;
NextButton.Content = "Close";
NextButton.IsEnabled = true;
CancelButton.IsEnabled = true;
_currentStep = 5;
});
}
}
private async Task RunUninstallAsync(string targetDir)
{
try
{
bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
if (isWindows)
{
windowsInstaller = new Installers.Windows();
await windowsInstaller.UninstallPeercordWindowsAsync(targetDir);
}
else if (isLinux)
{
linuxInstaller = new Installers.Linux();
await linuxInstaller.UninstallPeercordLinuxAsync(targetDir);
}
}
catch (Exception ex)
{
AppendLog($"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [ERROR] {ex.Message}\n");
SetProgress(100, "Uninstallation Failed.");
Dispatcher.UIThread.Post(() =>
{
_isBusy = false;
NextButton.Content = "Close";
NextButton.IsEnabled = true;
CancelButton.IsEnabled = true;
_currentStep = 5;
});
}
}
}

View File

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Platforms>AnyCPU;x64</Platforms>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\app.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.1" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.1" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.14">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Drawing.Common" Version="10.0.7" />
<PackageReference Include="System.Runtime.InteropServices" Version="4.3.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,36 @@
using Avalonia;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace PeercordInstaller
{
internal class Program
{
[DllImport("shell32.dll", SetLastError = true)]
private static extern int SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string appId);
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
//string appID = AppIdHelper.GetCurrentAppId();
//Debugger.Break();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
SetCurrentProcessExplicitAppUserModelID("com.peercord.app");
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@ -0,0 +1,70 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
namespace PeercordInstaller
{
[ComImport, Guid("00021401-0000-0000-C000-000000000046")]
internal class ShellLink { }
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
internal interface IShellLinkW
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, out IntPtr pfd, int fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
[ComImport, Guid("0000010b-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IPersistFile
{
void GetClassID(out Guid pClassID);
[PreserveSig] int IsDirty();
void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode);
void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, [MarshalAs(UnmanagedType.Bool)] bool fRemember);
void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName);
void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName);
}
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99")]
internal interface IPropertyStore
{
void GetCount(out uint cProps);
void GetAt(uint iProp, out PropertyKey pkey);
void GetValue(ref PropertyKey key, out PropVariant pv);
void SetValue(ref PropertyKey key, ref PropVariant pv);
void Commit();
}
[StructLayout(LayoutKind.Sequential)]
internal struct PropertyKey
{
public Guid fmtid;
public UIntPtr pid;
public PropertyKey(Guid guid, uint id) { fmtid = guid; pid = (UIntPtr)id; }
}
[StructLayout(LayoutKind.Explicit)]
internal struct PropVariant
{
[FieldOffset(0)] public ushort vt;
[FieldOffset(8)] public IntPtr ptr;
public void SetString(string value) { vt = 31; ptr = Marshal.StringToCoTaskMemUni(value); } // 31 = VT_LPWSTR
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PeercordInstaller.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@ -0,0 +1,19 @@
# Dependencies
node_modules/
# Source Files (Since you use Vite to build to /dist)
src/
public/
# Build Artifacts
# Note: Do NOT ignore /dist if your app runs from there
# but DO ignore it if you are staging the root index.js
# and don't want redundant build files included.
release/
*.log
# Git and IDE
.git/
.vscode/
.gitignore
.pearignore

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,34 @@
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default {
packagerConfig: {
asar: false, // Keep this false so the main app isn't an ASAR
icon: path.join(__dirname, 'assets', 'icon'),
ignore: (file) => {
if (!file) return false; // Don't ignore the root directory
// Normalize path to use forward slashes for cross-platform consistency
const normalizedPath = file.replace(/\\/g, '/');
// Ignore these folders so they don't bloat the build or cause nested ASAR crashes
if (normalizedPath.startsWith('/out')) return true;
if (normalizedPath.startsWith('/Test Bed')) return true;
if (normalizedPath.startsWith('/src')) return true;
if (normalizedPath.startsWith('/public')) return true;
if (normalizedPath.startsWith('/scripts')) return true;
if (normalizedPath.startsWith('/.git')) return true;
return false;
}
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-zip',
platforms: ['darwin', 'linux', 'win32'],
}
],
};

164
Peercord Source/index.html Normal file
View File

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peercord</title>
<!-- FIX: Changed to absolute path to prevent Vite bundling warning -->
<script src="/version.js"></script>
<script>
// Pear Router: Instantly redirect to compiled UI in the dist folder
if (typeof window !== 'undefined' && window.Pear && !window.location.pathname.includes('/dist/')) {
window.location.replace(new URL('dist/index.html', window.location.href).href);
}
</script>
<style>
/* INSTANT SPLASH SCREEN CSS */
#splash {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background-color: #313338;
z-index: 9999;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
#splash.fade-out {
opacity: 0;
visibility: hidden;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #5865F2;
animation: bounce 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(0.95); }
}
@keyframes bounce {
0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8,0,1,1); }
50% { transform: none; animation-timing-function: cubic-bezier(0,0,0.2,1); }
}
</style>
</head>
<body class="bg-[#313338] text-[#dbdee1] m-0 p-0 overflow-hidden">
<!-- This renders the exact millisecond the window opens -->
<div id="splash">
<div style="height: 28px; background-color: #1e1f22; display: flex; align-items: center; padding-left: 12px; font-size: 11px; font-weight: bold; color: #6b7280; letter-spacing: 0.05em; -webkit-app-region: drag; user-select: none;">
<img src="./assets/icon.png" alt="Logo" style="width: 16px; height: 16px; border-radius: 50%; margin-right: 8px; object-fit: cover;" />
Peercord
</div>
<div style="flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px;">
<img src="./assets/icon.png" alt="Malcord Logo" style="width: 96px; height: 96px; border-radius: 24px; animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; object-fit: cover;" />
<div style="display: flex; flex-direction: column; align-items: center; gap: 12px;">
<div style="display: flex; gap: 6px;">
<div class="dot" style="animation-delay: 0ms;"></div>
<div class="dot" style="animation-delay: 150ms;"></div>
<div class="dot" style="animation-delay: 300ms;"></div>
</div>
<div style="color: #9ca3af; font-size: 12px; font-weight: bold; letter-spacing: 0.3em; text-transform: uppercase; margin-top: 8px;">
Initializing Swarm
</div>
</div>
</div>
<!-- Version Indicator -->
<div style="position: absolute; bottom: 24px; right: 24px; display: flex; align-items: center; gap: 8px; background-color: rgba(0,0,0,0.2); padding: 4px 10px; border-radius: 9999px; border: 1px solid rgba(255,255,255,0.05);">
<div id="splash-version-dot" style="width: 6px; height: 6px; border-radius: 50%; background-color: #5865F2;"></div>
<span id="splash-version-text" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 10px; color: #9ca3af; font-weight: bold; padding-top: 1px;">v1.0.0</span>
</div>
</div>
<script>
if (typeof window !== 'undefined' && window.APP_VERSION) {
document.getElementById('splash-version-text').innerText = 'v' + window.APP_VERSION;
const dot = document.getElementById('splash-version-dot');
dot.style.backgroundColor = window.APP_VERSION_COLOR;
dot.style.boxShadow = '0 0 6px ' + window.APP_VERSION_COLOR;
}
</script>
<!-- FILE-BACKED LOCALSTORAGE POLYFILL -->
<!-- This completely bypasses Electron's flaky localStorage and writes directly to the OS file system -->
<script>
if (typeof window !== 'undefined' && window.require) {
try {
const fs = window.require('fs');
const path = window.require('path');
const os = window.require('os');
const home = os.homedir();
const appData = process.platform === 'win32'
? process.env.APPDATA
: (process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : path.join(home, '.config'));
const dir = path.join(appData || home, 'Peercord');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const storagePath = path.join(dir, 'app_storage.json');
let store = {};
let needsMigration = false;
if (fs.existsSync(storagePath)) {
try { store = JSON.parse(fs.readFileSync(storagePath, 'utf-8')); } catch (e) {}
} else {
// Migrate existing native localStorage data to prevent logging out Windows users
try {
if (window.localStorage && window.localStorage.length > 0) {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
store[key] = window.localStorage.getItem(key);
}
needsMigration = true;
}
} catch (e) {}
}
let saveTimeout = null;
const saveStore = () => {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
try { fs.writeFileSync(storagePath, JSON.stringify(store, null, 2)); } catch (e) {}
}, 100);
};
if (needsMigration) {
saveStore();
console.log("✅ Migrated existing localStorage to file-based storage.");
}
const customStorage = {
getItem: (key) => store.hasOwnProperty(key) ? String(store[key]) : null,
setItem: (key, value) => { store[key] = String(value); saveStore(); },
removeItem: (key) => { delete store[key]; saveStore(); },
clear: () => { store = {}; saveStore(); },
key: (i) => Object.keys(store)[i] || null,
get length() { return Object.keys(store).length; }
};
try { delete window.localStorage; } catch(e) {}
Object.defineProperty(window, 'localStorage', {
value: customStorage,
configurable: true,
enumerable: true,
writable: false
});
console.log("✅ Custom File-Based localStorage initialized at:", storagePath);
} catch (err) {
console.error("❌ Failed to initialize custom localStorage:", err);
}
}
</script>
<!-- React will mount the actual app underneath the splash screen -->
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

360
Peercord Source/index.js Normal file
View File

@ -0,0 +1,360 @@
import { app, BrowserWindow, ipcMain, desktopCapturer, protocol, net } from 'electron';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import fs from 'fs';
import { spawn } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// We need to read package.json for version and upgrade link
const pkgPath = path.join(__dirname, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
// Register custom protocol BEFORE app is ready to ensure localStorage persistence
// FIX: Added stream: true to allow <video> and <audio> tags to stream media properly
protocol.registerSchemesAsPrivileged([
{ scheme: 'peercord', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: true, corsEnabled: true, stream: true } }
]);
// Force app name and userData path BEFORE app.whenReady()
// This ensures localStorage and user data is identical regardless of which .exe launches the app
app.name = 'Peercord';
const appDataPath = path.join(app.getPath('appData'), 'Peercord');
app.setPath('userData', appDataPath);
// Enforce Single Instance Lock
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
process.exit(0);
}
// Release the Windows directory lock!
if (!fs.existsSync(appDataPath)) {
fs.mkdirSync(appDataPath, { recursive: true });
}
try {
process.chdir(appDataPath);
} catch (e) {
console.error("Failed to change CWD:", e);
}
function getAppDir() {
// Since we disabled ASAR, app.isPackaged will be FALSE even in production!
// We must detect dev mode by checking if the executable is named 'electron'
const execName = path.basename(process.execPath).toLowerCase();
const isDev = execName === 'electron.exe' || execName === 'electron';
if (isDev) return null;
if (process.platform === 'linux' && process.env.APPIMAGE) return process.env.APPIMAGE;
if (process.platform === 'darwin') return path.join(process.resourcesPath, '..', '..');
// Return the DIRECTORY containing the binary for Windows and Linux
// PearRuntime expects the directory to hash and verify against the seeded drive!
return path.dirname(process.execPath);
}
let globalWin = null;
let isWindowReady = false;
let logQueue = [];
// Focus existing window if a second instance is launched
app.on('second-instance', (event, commandLine, workingDirectory) => {
if (globalWin) {
if (globalWin.isMinimized()) globalWin.restore();
globalWin.focus();
}
});
// Custom logger to pipe Main Process logs to the React F10 Console
function logToRenderer(level, ...args) {
const formattedArgs = args.map(a => {
if (a instanceof Error) return `${a.name}: ${a.message}\n${a.stack}`;
if (typeof a === 'object') {
try { return JSON.stringify(a, Object.getOwnPropertyNames(a)); } catch(e) { return String(a); }
}
return String(a);
});
console[level](...formattedArgs);
// Queue logs if the React window hasn't finished loading yet!
if (globalWin && isWindowReady && !globalWin.isDestroyed()) {
globalWin.webContents.send('main-log', { level, args: formattedArgs });
} else {
logQueue.push({ level, args: formattedArgs });
}
}
// Flush the log queue the millisecond React says it's ready
ipcMain.on('renderer-ready', () => {
isWindowReady = true;
logQueue.forEach(log => {
if (globalWin && !globalWin.isDestroyed()) {
globalWin.webContents.send('main-log', log);
}
});
logQueue = [];
});
async function boot() {
// Handle Squirrel.Windows startup events to prevent multiple background launches
if (process.platform === 'win32') {
const cmd = process.argv[1];
if (cmd === '--squirrel-install' || cmd === '--squirrel-updated' || cmd === '--squirrel-uninstall' || cmd === '--squirrel-obsolete') {
app.quit();
return;
}
}
// Prevents "GPU process exited unexpectedly: exit_code=1" on Windows
app.disableHardwareAcceleration();
await app.whenReady();
const appPath = app.getAppPath();
// Handle custom protocol for consistent localStorage origin
// This prevents Linux from wiping localStorage when the executable path changes
protocol.handle('peercord', (request) => {
// Safely serve local files (images/videos) bypassing Electron's file:// restrictions
if (request.url.startsWith('peercord://local/')) {
let filePath = decodeURIComponent(request.url.replace('peercord://local/', ''));
if (process.platform === 'win32' && filePath.startsWith('/')) {
filePath = filePath.substring(1); // Remove leading slash on Windows
}
return net.fetch(pathToFileURL(filePath).href);
}
let url = request.url.replace('peercord://app/', '');
url = url.split('?')[0].split('#')[0];
url = decodeURIComponent(url);
if (url.startsWith('/')) url = url.substring(1);
let filePath = path.join(appPath, url);
// Fallback to dist/ if not found (helps with absolute paths in index.html)
if (!fs.existsSync(filePath)) {
const distPath = path.join(appPath, 'dist', url);
if (fs.existsSync(distPath)) filePath = distPath;
}
return net.fetch(pathToFileURL(filePath).href);
});
// Dynamically select the correct icon format based on the OS
const iconFile = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
const win = new BrowserWindow({
width: 1100,
height: 750,
title: "Peercord",
icon: path.join(appPath, 'assets', iconFile),
frame: false,
resizable: true,
maximizable: true,
thickFrame: true,
backgroundColor: '#313338',
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
globalWin = win;
// Load via custom protocol instead of file:// to prevent localStorage wipes on Linux
win.loadURL('peercord://app/dist/index.html');
// ALLOW F12 TO OPEN DEVTOOLS
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12') {
win.webContents.toggleDevTools();
event.preventDefault();
}
});
// Bridge for TitleBar.jsx custom controls
ipcMain.on('window-action', (event, action) => {
if (action === 'minimize') win.minimize();
if (action === 'maximize') win.isMaximized() ? win.restore() : win.maximize();
if (action === 'close') win.close();
});
// Sync window state back to React for the maximize/restore icon
win.on('maximize', () => win.webContents.send('window-state-changed', true));
win.on('unmaximize', () => win.webContents.send('window-state-changed', false));
// Bridge for ScreenShareModal.jsx desktop sources
ipcMain.handle('get-desktop-sources', async () => {
const sources = await desktopCapturer.getSources({
types:['window', 'screen'],
thumbnailSize: { width: 320, height: 180 }
});
return sources.map(s => ({
id: s.id,
name: s.name,
thumbnailDataURL: s.thumbnail.toDataURL()
}));
});
// ---------------------------------------------------------
// PEAR OTA UPDATER LOGIC
// ---------------------------------------------------------
// Initialize Embedded Pear Runtime AFTER window creation so logs are queued properly
let pear = null;
try {
const { default: PearRuntime } = await import('pear-runtime');
const resolvedAppDir = getAppDir();
logToRenderer('info', '[Pear] Resolved App Directory for Updater:', resolvedAppDir);
pear = new PearRuntime({
...pkg, // Spread pkg to ensure updates: true is passed
dir: path.join(app.getPath('userData'), 'pear-data'),
app: resolvedAppDir
});
pear.on('error', (err) => logToRenderer('error', '[Pear Error]', err));
} catch (e) {
logToRenderer('error', '[Pear] Failed to initialize PearRuntime (Likely missing native modules for this OS. Did you build Linux on Windows?):', e.message, e.stack);
}
if (pear && pear.updater) {
logToRenderer('info', '[Pear] Updater initialized. Current version:', pkg.version);
pear.updater.on('updating', () => {
logToRenderer('info', '[Pear] updating event fired. Downloading update...');
if (win && !win.isDestroyed()) win.webContents.send('pear-updating');
});
pear.updater.on('updated', () => {
logToRenderer('info', '[Pear] updated event fired. Update downloaded and ready.');
if (win && !win.isDestroyed()) win.webContents.send('pear-updated');
});
pear.updater.on('error', (err) => {
logToRenderer('error', '[Pear] Updater Error:', err);
if (win && !win.isDestroyed()) {
win.webContents.send('pear-error', err instanceof Error ? err.message : String(err));
}
});
} else {
logToRenderer('warn', '[Pear] Updater not available on pear object');
}
// Safe restart for Gossip protocol (reboots to find new seeder)
ipcMain.on('normal-restart', () => {
app.relaunch();
app.quit();
});
ipcMain.on('apply-update', async () => {
logToRenderer('info', '[Pear] apply-update requested by renderer');
try {
const baseDir = getAppDir();
if (!baseDir) throw new Error("Cannot apply update in dev mode");
const nextDir = path.join(appDataPath, 'pear-data', 'pear-runtime', 'next');
if (!fs.existsSync(nextDir)) throw new Error("Update cache directory not found");
const versions = fs.readdirSync(nextDir).filter(v => fs.statSync(path.join(nextDir, v)).isDirectory());
if (versions.length === 0) throw new Error("No downloaded updates found");
versions.sort((a, b) => parseFloat(b) - parseFloat(a));
const latestVersion = versions[0];
const archDir = `${process.platform}-${process.arch}`;
const updateAppPath = path.join(nextDir, latestVersion, 'by-arch', archDir, 'app', 'peercord');
if (!fs.existsSync(updateAppPath)) throw new Error(`Update files not found at ${updateAppPath}`);
if (process.platform === 'win32') {
logToRenderer('info', '[Pear] Windows detected. Using detached script to bypass OS file locks...');
const batPath = path.join(app.getPath('temp'), `peercord-update-${Date.now()}.bat`);
const vbsPath = path.join(app.getPath('temp'), `peercord-update-${Date.now()}.vbs`);
const batContent = `
@echo off
:wait
tasklist /FI "PID eq ${process.pid}" /NH | findstr /C:"${process.pid}" > nul
if %ERRORLEVEL% == 0 (
timeout /t 1 /nobreak > nul
goto wait
)
xcopy /E /Y /I /H /C "${updateAppPath}\\*" "${baseDir}\\"
start "" "${process.execPath}"
del "%~1"
del "%~f0"
`;
const vbsContent = `
Dim WshShell
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "cmd.exe /c """"" & WScript.Arguments(0) & """ """ & WScript.Arguments(1) & """""", 0, False
`;
fs.writeFileSync(batPath, batContent);
fs.writeFileSync(vbsPath, vbsContent);
const child = spawn('wscript.exe', [vbsPath, batPath, vbsPath], {
detached: true,
stdio: 'ignore',
windowsHide: true
});
child.unref();
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
app.quit();
} else {
logToRenderer('info', '[Pear] macOS/Linux detected. Using detached bash script for reliable directory swap...');
const shPath = path.join(app.getPath('temp'), `peercord-update-${Date.now()}.sh`);
const shContent = `#!/bin/bash
# Wait for the Electron process to exit
while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.5
done
# Copy the new app directory contents into place, overwriting existing files
cp -rf "${updateAppPath}/." "${baseDir}/"
# Ensure the new binary is executable
chmod -R 755 "${baseDir}"
# Launch the new app
"${process.execPath}" &
# Delete this script
rm "$0"
`;
fs.writeFileSync(shPath, shContent);
fs.chmodSync(shPath, '755');
const child = spawn(shPath, [], {
detached: true,
stdio: 'ignore'
});
child.unref();
logToRenderer('info', '[Pear] Detached script spawned. Quitting app to allow swap...');
app.quit();
}
} catch (err) {
logToRenderer('error', '[Pear] Failed to apply update:', err);
if (globalWin && !globalWin.isDestroyed()) {
globalWin.webContents.send('pear-error', err instanceof Error ? err.message : String(err));
}
}
});
}
boot().catch(console.error);

View File

@ -0,0 +1,72 @@
{
"name": "peercord",
"version": "1.0.0",
"description": "Peercord, A P2P Discord clone powered by Pear Runtime",
"author": "Mastercodeon",
"main": "index.js",
"type": "module",
"upgrade": "[PEAR_LINK]",
"updates": true,
"scripts": {
"bump": "node scripts/version.js",
"dev": "vite",
"build:ui": "vite build",
"start": "electron-forge start",
"package": "npm run bump && npm run build:ui && electron-forge package",
"package:win": "npm run build:ui && electron-forge package --platform win32 --arch x64",
"package:mac": "npm run build:ui && electron-forge package --platform darwin --arch arm64",
"package:linux": "npm run build:ui && electron-forge package --platform linux --arch x64",
"make": "npm run bump && npm run build:ui && electron-forge make",
"make:win": "npm run build:ui && electron-forge make --platform win32 --arch x64",
"make:mac": "npm run build:ui && electron-forge make --platform darwin --arch arm64",
"make:linux": "npm run build:ui && electron-forge make --platform linux --arch x64",
"pear:clean": "node -e \"const fs=require('fs'); fs.rmSync('out/build', {recursive:true, force:true});\"",
"pear:prepare:win": "node -e \"const fs=require('fs'); fs.rmSync('out/win', {recursive:true, force:true}); fs.mkdirSync('out/win', {recursive:true}); fs.renameSync('out/peercord-win32-x64', 'out/win/peercord');\"",
"pear:prepare:mac": "node -e \"const fs=require('fs'); fs.rmSync('out/mac', {recursive:true, force:true}); fs.mkdirSync('out/mac', {recursive:true}); fs.renameSync('out/peercord-darwin-arm64', 'out/mac/peercord');\"",
"pear:prepare:linux": "node -e \"const fs=require('fs'); fs.rmSync('out/linux', {recursive:true, force:true}); fs.mkdirSync('out/linux', {recursive:true}); fs.renameSync('out/peercord-linux-x64', 'out/linux/peercord');\"",
"pear:build:win": "pear build --package=package.json --win32-x64-app out/win/peercord --target out/build",
"pear:build:mac": "pear build --package=package.json --darwin-arm64-app out/mac/peercord --target out/build",
"pear:build:linux": "pear build --package=package.json --linux-x64-app out/linux/peercord --target out/build",
"pear:build:multi": "pear build --package=package.json --win32-x64-app out/win/peercord --linux-x64-app out/linux/peercord --target out/build",
"pear:stage": "pear stage [PEAR_LINK] out/build",
"pear:seed": "pear seed [PEAR_LINK]",
"broadcast": "node scripts/broadcast-update.js",
"genkeys": "node scripts/genkeys.js",
"release:win": "npm run bump && npm run make:win && npm run pear:clean && npm run pear:prepare:win && npm run pear:build:win && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:win:fast": "npm run bump && npm run package:win && npm run pear:clean && npm run pear:prepare:win && npm run pear:build:win && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:mac": "npm run bump && npm run make:mac && npm run pear:clean && npm run pear:prepare:mac && npm run pear:build:mac && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:mac:fast": "npm run bump && npm run package:mac && npm run pear:clean && npm run pear:prepare:mac && npm run pear:build:mac && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:linux": "npm run bump && npm run make:linux && npm run pear:clean && npm run pear:prepare:linux && npm run pear:build:linux && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:linux:fast": "npm run bump && npm run package:linux && npm run pear:clean && npm run pear:prepare:linux && npm run pear:build:linux && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:multi": "npm run bump && npm run make:win && npm run make:linux && npm run pear:clean && npm run pear:prepare:win && npm run pear:prepare:linux && npm run pear:build:multi && npm run pear:stage && npm run broadcast && npm run pear:seed",
"release:multi:fast": "npm run bump && npm run package:win && npm run package:linux && npm run pear:clean && npm run pear:prepare:win && npm run pear:prepare:linux && npm run pear:build:multi && npm run pear:stage && npm run broadcast && npm run pear:seed"
},
"dependencies": {
"autobase": "latest",
"b4a": "latest",
"corestore": "latest",
"hyperbee": "latest",
"hypercore": "latest",
"hyperswarm": "latest",
"pear-runtime": "latest",
"react": "latest",
"react-dom": "latest",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"sodium-native": "latest"
},
"devDependencies": {
"@electron-forge/cli": "^7.3.0",
"@electron-forge/maker-squirrel": "^7.3.0",
"@electron-forge/maker-zip": "^7.3.0",
"@tailwindcss/typography": "^0.5.10",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"electron": "^29.0.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
}
}

13
Peercord Source/pear.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "p2p-discord-clone",
"type": "desktop",
"main": "index.js",
"gui": {
"main": "dist/index.html",
"frame": false,
"resizable": true,
"maximizable": true,
"thickFrame": true
},
"pre": "pear-electron/pre"
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,2 @@
window.APP_VERSION = '1.0.0';
window.APP_VERSION_COLOR = 'hsl(117, 80%, 60%)';

View File

@ -0,0 +1,62 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import Hyperswarm from 'hyperswarm';
import b4a from 'b4a';
import sodium from 'sodium-native';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8'));
// The private admin seed used to sign updates
const ADMIN_SEED_HEX = '[PLACE_HOLDER]';
const seedBuf = b4a.from(ADMIN_SEED_HEX, 'hex');
const pubKey = b4a.alloc(32);
const secKey = b4a.alloc(64);
sodium.crypto_sign_seed_keypair(pubKey, secKey, seedBuf);
const version = pkg.version;
const timestamp = Date.now();
// Create cryptographic signature to prevent fake updates
const msgBuf = b4a.from(version + timestamp);
const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sigBuf, msgBuf, secKey);
const payload = {
type: 'system_update',
version,
timestamp,
signature: b4a.toString(sigBuf, 'hex')
};
console.log(`\n🚀 [Broadcast] Announcing update v${version} to P2P network...`);
const swarm = new Hyperswarm();
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-global-updates'));
let peersConnected = 0;
swarm.on('connection', (conn) => {
peersConnected++;
console.log(`📡 [Broadcast] Connected to peer ${peersConnected}. Sending payload...`);
const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload }));
conn.write(msg);
});
// Join the global updates mesh as both client and server to maximize holepunching success
const discovery = swarm.join(topic, { client: true, server: true });
console.log("⏳ [Broadcast] Searching DHT for peers...");
// Wait for the DHT search to exhaustively complete
await discovery.flushed();
console.log("⏳ [Broadcast] DHT search complete. Waiting 5 seconds for connections to establish...");
// Give it a few seconds for NAT holepunching and connections to fully establish
setTimeout(async () => {
console.log(`✅ [Broadcast] Finished. Reached ${peersConnected} direct peers. The Gossip protocol will flood the rest of the network instantly.`);
await swarm.destroy();
process.exit(0);
}, 5000);

View File

@ -0,0 +1,17 @@
import b4a from 'b4a';
import sodium from 'sodium-native';
import Hyperswarm from 'hyperswarm';
// 1. GENERATE YOUR KEYS
const seed = b4a.alloc(32);
sodium.randombytes_buf(seed);
const pubKey = b4a.alloc(32);
const secKey = b4a.alloc(64);
sodium.crypto_sign_seed_keypair(pubKey, secKey, seed);
console.log("YOUR SECRET SEED:", b4a.toString(seed, 'hex'));
console.log("YOUR PUBLIC KEY (Put in utils.js):", b4a.toString(pubKey, 'hex'));
// Force Node to exit and kill background network pools
process.exit(0);

View File

@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Read package.json
const pkgPath = path.resolve(__dirname, '../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
let currentVersion = pkg.version;
let [major, minor, patch] = currentVersion.split('.').map(Number);
// Increment the patch version
patch += 1;
// Rollover logic: 1000 patches = 1 minor
if (patch >= 1000) {
patch = 0;
minor += 1;
}
// Rollover logic: 10 minors = 1 major
if (minor >= 10) {
minor = 0;
major += 1;
}
const newVersion = `${major}.${minor}.${patch}`;
pkg.version = newVersion;
// Write back to package.json
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
// Ensure the public directory exists
const publicDir = path.resolve(__dirname, '../public');
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// Generate deterministic color based on the new version string
let hash = 0;
for (let i = 0; i < newVersion.length; i++) {
hash = newVersion.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
const color = `hsl(${hue}, 80%, 60%)`;
// Write to a JS file that can be loaded synchronously in the HTML head
const content = `window.APP_VERSION = '${newVersion}';\nwindow.APP_VERSION_COLOR = '${color}';\n`;
fs.writeFileSync(path.join(publicDir, 'version.js'), content);
console.log(`[Versioning] Bumped version from ${currentVersion} to v${newVersion} with color: ${color}`);

356
Peercord Source/src/App.jsx Normal file
View File

@ -0,0 +1,356 @@
import React, { useEffect, useState, useRef } from 'react';
import SetupScreen from './components/SetupScreen.jsx';
import MainApp from './components/MainApp.jsx';
import TitleBar from './components/TitleBar.jsx';
import ConsoleOverlay from './components/ConsoleOverlay.jsx';
import { network, initP2P } from './p2p/index.js';
export default function App() {
const[profile, setProfile] = useState(null);
const[isLoaded, setIsLoaded] = useState(false);
const[showConsole, setShowConsole] = useState(false);
const [logs, setLogs] = useState([]);
// Auto-Updater States
const[updateState, setUpdateState] = useState(null); // 'downloading', 'available', 'countdown', 'gossip_available', 'gossip_countdown'
const[flyoutDismissed, setFlyoutDismissed] = useState(false);
const[countdown, setCountdown] = useState(5);
const[busyReasons, setBusyReasons] = useState([]);
const countdownRef = useRef(null);
const triggerRestart = () => {
if (typeof window !== 'undefined' && window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('apply-update');
} else {
window.location.reload();
}
};
useEffect(() => {
if (updateState === 'countdown' || updateState === 'gossip_countdown') {
countdownRef.current = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(countdownRef.current);
if (updateState === 'gossip_countdown') {
if (typeof window !== 'undefined' && window.require) {
window.require('electron').ipcRenderer.send('normal-restart');
} else {
window.location.reload();
}
} else {
triggerRestart();
}
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => clearInterval(countdownRef.current);
}, [updateState]);
useEffect(() => {
// Load Custom Theme
const savedTheme = JSON.parse(localStorage.getItem('peercord_theme'));
if (savedTheme) {
Object.entries(savedTheme).forEach(([key, val]) => {
document.documentElement.style.setProperty(`--color-${key}`, val);
});
}
const origLog = console.log;
const origWarn = console.warn;
const origError = console.error;
const origInfo = console.info;
const safeStringify = (obj) => {
if (typeof obj === 'string') return obj;
if (obj instanceof Error) return obj.stack || obj.message || String(obj);
try {
return JSON.stringify(obj, null, 2);
} catch (e) {
return String(obj);
}
};
const addLog = (type, args) => {
const msg = args.map(safeStringify).join(' ');
setLogs(prev => {
const newLogs =[...prev, { type, msg, time: new Date().toLocaleTimeString() }];
return newLogs.slice(-200);
});
};
console.log = (...args) => { addLog('log', args); origLog(...args); };
console.warn = (...args) => { addLog('warn', args); origWarn(...args); };
console.error = (...args) => { addLog('error', args); origError(...args); };
console.info = (...args) => { addLog('info', args); origInfo(...args); };
console.log("🚀 Peercord UI successfully booted! F10 Console is active.");
// Setup IPC Listeners for Pear Auto-Updater & Main Process Logs
let cleanupIpc = null;
if (typeof window !== 'undefined' && window.require) {
const { ipcRenderer } = window.require('electron');
const handleMainLog = (e, { level, args }) => {
addLog(level, ['[MAIN]', ...args]);
};
const handleUpdating = () => {
console.log("🚀 [Pear Updater] Downloading update...");
setUpdateState('downloading');
setFlyoutDismissed(false);
};
const handleUpdated = () => {
console.log("🚀 [Pear Updater] New version downloaded!");
addLog('info', ["🚀 [Pear Updater] New version downloaded!"]);
const autoRestart = localStorage.getItem('pear_auto_restart') !== 'false';
const reasons = network.getBusyReasons();
const isBusy = reasons.length > 0;
setFlyoutDismissed(false);
if (autoRestart && !isBusy) {
setUpdateState('countdown');
setCountdown(5);
} else {
setUpdateState('available');
setBusyReasons(reasons);
}
};
const handlePearError = (e, errMsg) => {
console.error("🚀 [Pear Updater] Failed:", errMsg);
setUpdateState(null);
setFlyoutDismissed(true);
};
ipcRenderer.on('main-log', handleMainLog);
ipcRenderer.on('pear-updating', handleUpdating);
ipcRenderer.on('pear-updated', handleUpdated);
ipcRenderer.on('pear-error', handlePearError);
// FIX: Tell the main process we are ready to receive the queued logs!
ipcRenderer.send('renderer-ready');
cleanupIpc = () => {
ipcRenderer.removeListener('main-log', handleMainLog);
ipcRenderer.removeListener('pear-updating', handleUpdating);
ipcRenderer.removeListener('pear-updated', handleUpdated);
ipcRenderer.removeListener('pear-error', handlePearError);
};
}
initP2P().then(async () => {
const storedIdentity = localStorage.getItem('pear_discord_identity');
if (storedIdentity) {
try {
setProfile(JSON.parse(storedIdentity));
} catch (err) {
console.error("Failed to parse identity:", err);
}
}
setIsLoaded(true);
const splashTimer = setTimeout(() => {
const splashEl = document.getElementById('splash');
if (splashEl) {
splashEl.classList.add('fade-out');
setTimeout(() => { splashEl.remove(); }, 300);
}
}, 2000);
}).catch(err => {
alert("CRITICAL ERROR: Failed to load P2P modules.\n\n" + err.message + "\n\nPress F12 for DevTools.");
console.error(err);
});
const handleBeforeUnload = () => { network.sendOffline(); };
window.addEventListener('beforeunload', handleBeforeUnload);
const handleKeyDown = (e) => {
if (e.key === 'F10') setShowConsole(prev => !prev);
if (e.key === 'F12') {
if (typeof Pear !== 'undefined' && Pear.Window && Pear.Window.self) {
if (typeof Pear.Window.self.inspect === 'function') Pear.Window.self.inspect();
else if (typeof Pear.Window.self.openDevTools === 'function') Pear.Window.self.openDevTools();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
console.log = origLog;
console.warn = origWarn;
console.error = origError;
console.info = origInfo;
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('keydown', handleKeyDown);
if (cleanupIpc) cleanupIpc();
};
},[]);
const handleLogout = () => {
localStorage.removeItem('pear_discord_identity');
network.sendOffline();
setTimeout(async () => { await network.close(); }, 100);
setProfile(null);
};
if (!isLoaded) return null;
return (
<div className="flex flex-col h-screen w-full bg-base overflow-hidden relative">
<TitleBar />
<div className="flex-1 relative overflow-hidden flex">
{profile ? (
<MainApp
profile={profile}
setProfile={setProfile}
onLogout={handleLogout}
updateState={updateState}
triggerRestart={triggerRestart}
onSystemUpdate={(version, payload) => {
const seenKey = `seen_update_${version}`;
if (!sessionStorage.getItem(seenKey)) {
sessionStorage.setItem(seenKey, 'true');
console.info(`🚀 [P2P] Verified Admin Update Broadcast: v${version}`);
const autoRestart = localStorage.getItem('pear_auto_restart') !== 'false';
const reasons = network.getBusyReasons();
const isBusy = reasons.length > 0;
setUpdateState(prev => {
if (prev === 'downloading' || prev === 'available' || prev === 'countdown') return prev;
if (autoRestart && !isBusy) {
setCountdown(5);
return 'gossip_countdown';
} else {
setBusyReasons(reasons);
return 'gossip_available';
}
});
setFlyoutDismissed(false);
// Gossip to all connected peers to ensure network-wide delivery instantly
network.sendEphemeral(payload);
}
}}
/>
) : (
<SetupScreen setProfile={setProfile} />
)}
</div>
{showConsole && <ConsoleOverlay logs={logs} onClose={() => setShowConsole(false)} />}
{/* Update Notification Flyout */}
{updateState && !flyoutDismissed && (
<div className="absolute bottom-6 right-6 bg-surface border border-panel shadow-2xl rounded-lg p-4 w-80 z-[9999] flex flex-col gap-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent flex items-center justify-center text-white shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 2v6h-6"></path><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 2v6h6"></path><path d="M21 12a9 9 0 1 0-9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path></svg>
</div>
<div>
<h3 className="text-text font-bold text-sm">
{updateState === 'downloading' ? 'Downloading Update...' :
(updateState === 'gossip_available' || updateState === 'gossip_countdown') ? 'Update Broadcasted' : 'Update Available'}
</h3>
<p className="text-muted text-[11px] leading-tight mt-0.5">
{updateState === 'downloading' ? 'A new version is being downloaded.' :
(updateState === 'gossip_available' || updateState === 'gossip_countdown') ? 'A new version has been announced on the network.' : 'A new version of Peercord is ready.'}
</p>
</div>
</div>
<button onClick={() => setFlyoutDismissed(true)} className="text-muted hover:text-text transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
{updateState === 'downloading' ? (
<div className="flex flex-col gap-1 mt-1">
<div className="w-full bg-base rounded-full h-1.5 overflow-hidden relative">
<div className="bg-accent h-1.5 rounded-full absolute top-0 left-0 w-1/2 animate-indeterminate"></div>
</div>
<div className="flex justify-between text-[10px] text-muted mt-1">
<span>Downloading update...</span>
</div>
</div>
) : updateState === 'countdown' ? (
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-muted font-medium">Restarting in {countdown}s...</span>
<div className="flex gap-2">
<button onClick={() => { setUpdateState('available'); clearInterval(countdownRef.current); setFlyoutDismissed(true); }} className="px-3 py-1.5 text-xs font-medium text-muted hover:text-text hover:underline">Cancel</button>
<button onClick={triggerRestart} className="px-3 py-1.5 text-xs font-medium bg-accent text-white rounded transition-colors opacity-90 hover:opacity-100">Restart Now</button>
</div>
</div>
) : updateState === 'gossip_available' || updateState === 'gossip_countdown' ? (
<div className="flex flex-col gap-2 mt-1">
{busyReasons.length > 0 && (localStorage.getItem('pear_auto_restart') !== 'false') && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded p-2 mb-1">
<span className="text-[10px] text-yellow-500 font-bold uppercase block mb-1">Auto-Restart Paused:</span>
<ul className="text-[10px] text-yellow-400/80 list-disc pl-4">
{busyReasons.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
<span className="text-sm text-green-500 font-bold">New Update Broadcasted!</span>
<span className="text-xs text-muted">
{updateState === 'gossip_countdown'
? `Restarting in ${countdown}s to connect to the new seeder...`
: 'Restart the app to connect to the new seeder and begin downloading.'}
</span>
<div className="flex justify-end gap-2 mt-2">
{updateState === 'gossip_countdown' ? (
<>
<button onClick={() => { setUpdateState('gossip_available'); clearInterval(countdownRef.current); setFlyoutDismissed(true); }} className="px-3 py-1.5 text-xs font-medium text-muted hover:text-text hover:underline">Cancel</button>
<button onClick={() => {
if (typeof window !== 'undefined' && window.require) {
window.require('electron').ipcRenderer.send('normal-restart');
} else {
window.location.reload();
}
}} className="px-3 py-1.5 text-xs font-medium bg-accent text-white rounded transition-colors opacity-90 hover:opacity-100">Restart Now</button>
</>
) : (
<>
<button onClick={() => setFlyoutDismissed(true)} className="px-3 py-1.5 text-xs font-medium text-muted hover:text-text hover:underline">Later</button>
<button onClick={() => {
if (typeof window !== 'undefined' && window.require) {
window.require('electron').ipcRenderer.send('normal-restart');
} else {
window.location.reload();
}
}} className="px-3 py-1.5 text-xs font-medium bg-accent text-white rounded transition-colors opacity-90 hover:opacity-100">Restart & Download</button>
</>
)}
</div>
</div>
) : (
<div className="flex flex-col gap-2 mt-1">
{busyReasons.length > 0 && (localStorage.getItem('pear_auto_restart') !== 'false') && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded p-2 mb-1">
<span className="text-[10px] text-yellow-500 font-bold uppercase block mb-1">Auto-Restart Paused:</span>
<ul className="text-[10px] text-yellow-400/80 list-disc pl-4">
{busyReasons.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
<button onClick={() => setFlyoutDismissed(true)} className="px-3 py-1.5 text-xs font-medium text-muted hover:text-text hover:underline">Later</button>
<button onClick={triggerRestart} className="px-3 py-1.5 text-xs font-medium bg-accent text-white rounded transition-colors opacity-90 hover:opacity-100">Restart Now</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,639 @@
import React, { useEffect, useRef, useState } from 'react';
import { network } from '../p2p/index.js';
import ScreenShareModal from './ScreenShareModal.jsx';
export default function CallView({ targetKey, targetProfile, myProfile, isCaller, status, onClose, onToggleChat, onConnected, className, initialVideoOn }) {
const [isMuted, setIsMuted] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(initialVideoOn || false);
const [localVoiceActive, setLocalVoiceActive] = useState(false);
const [remoteVoiceActive, setRemoteVoiceActive] = useState(false);
const [showScreenShareModal, setShowScreenShareModal] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [hasRemoteVideo, setHasRemoteVideo] = useState(false);
const [remoteVideoStream, setRemoteVideoStream] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const pcRef = useRef(null);
const localStreamRef = useRef(null);
const localScreenStreamRef = useRef(null);
const localCameraStreamRef = useRef(null);
const remoteAudioRef = useRef(null);
const remoteVideoRef = useRef(null);
const localVideoRef = useRef(null);
const animationFrameRef = useRef(null);
const audioCtxRef = useRef(null);
const pendingCandidates = useRef([]);
const pendingSignals = useRef([]);
const isProcessingSignals = useRef(false);
const drainPendingSignalsRef = useRef(null);
const [mediaReady, setMediaReady] = useState(false);
const isLocalVideoActive = isScreenSharing || isVideoOn;
const isVideoActive = hasRemoteVideo || isLocalVideoActive;
useEffect(() => {
if (localVideoRef.current) {
if (isScreenSharing && localScreenStreamRef.current) {
localVideoRef.current.srcObject = localScreenStreamRef.current;
} else if (isVideoOn && localCameraStreamRef.current) {
localVideoRef.current.srcObject = localCameraStreamRef.current;
} else {
localVideoRef.current.srcObject = null;
}
}
}, [isScreenSharing, isVideoOn, hasRemoteVideo, isFullscreen]);
useEffect(() => {
if (remoteVideoRef.current) {
if (hasRemoteVideo && remoteVideoStream) {
remoteVideoRef.current.srcObject = remoteVideoStream;
} else {
remoteVideoRef.current.srcObject = null;
}
}
}, [hasRemoteVideo, isFullscreen, remoteVideoStream]);
const initPC = async () => {
const pc = new RTCPeerConnection({ iceServers:[{ urls: 'stun:stun.l.google.com:19302' }] });
pcRef.current = pc;
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current));
}
if (localCameraStreamRef.current) {
localCameraStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localCameraStreamRef.current));
}
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') {
onConnected();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
onConnected();
}
};
pc.onicecandidate = (e) => {
if (e.candidate) {
network.sendWebRTCSignal(targetKey, { type: 'webrtc-ice-candidate', candidate: e.candidate });
}
};
pc.ontrack = (e) => {
if (e.track.kind === 'video') {
setRemoteVideoStream(e.streams[0]);
setHasRemoteVideo(true);
e.track.onended = () => {
setHasRemoteVideo(false);
setRemoteVideoStream(null);
setIsFullscreen(false);
};
e.track.onmute = () => {
setHasRemoteVideo(false);
setIsFullscreen(false);
};
e.track.onunmute = () => {
setHasRemoteVideo(true);
};
} else if (e.track.kind === 'audio') {
if (remoteAudioRef.current) {
remoteAudioRef.current.srcObject = e.streams[0];
const outputId = localStorage.getItem('pear_audio_output');
if (outputId && outputId !== 'default' && remoteAudioRef.current.setSinkId) {
remoteAudioRef.current.setSinkId(outputId).catch(console.error);
}
}
}
};
if (drainPendingSignalsRef.current) {
await drainPendingSignalsRef.current();
}
return pc;
};
useEffect(() => {
const setupMedia = async () => {
try {
const audioInputId = localStorage.getItem('pear_audio_input');
const aStream = await navigator.mediaDevices.getUserMedia({
audio: audioInputId && audioInputId !== 'default' ? { deviceId: { exact: audioInputId } } : true
});
localStreamRef.current = aStream;
if (initialVideoOn) {
try {
const videoInputId = localStorage.getItem('pear_video_input');
const vStream = await navigator.mediaDevices.getUserMedia({
video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
});
localCameraStreamRef.current = vStream;
} catch (err) {
console.error("Failed to get video", err);
setIsVideoOn(false);
}
}
setMediaReady(true);
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
audioCtxRef.current = audioCtx;
const analyser = audioCtx.createAnalyser();
const source = audioCtx.createMediaStreamSource(aStream);
source.connect(analyser);
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let lastSpeakingState = false;
const checkAudio = () => {
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
const average = sum / bufferLength;
const isSpeaking = average > 15;
if (isSpeaking !== lastSpeakingState) {
network.sendWebRTCSignal(targetKey, { type: 'voice_activity', state: isSpeaking ? 'speaking' : 'silent' });
setLocalVoiceActive(isSpeaking);
lastSpeakingState = isSpeaking;
}
animationFrameRef.current = requestAnimationFrame(checkAudio);
};
checkAudio();
if (!isCaller) {
initPC();
}
} catch (err) {
console.error("Failed to access microphone:", err);
alert("Could not access microphone. Please check your permissions.");
onClose();
}
};
setupMedia();
return () => {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
if (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop());
if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
if (pcRef.current) pcRef.current.close();
network.sendWebRTCSignal(targetKey, { type: 'webrtc-end' });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);
useEffect(() => {
if (isCaller && status === 'connecting' && !pcRef.current && mediaReady) {
initPC().then(async (pc) => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},[status, isCaller, targetKey, mediaReady]);
useEffect(() => {
const processSignal = async (payload) => {
const pc = pcRef.current;
if (!pc) return;
if (payload.type === 'webrtc-offer') {
await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
for (const candidate of pendingCandidates.current) {
await pc.addIceCandidate(candidate).catch(console.error);
}
pendingCandidates.current =[];
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-answer', sdp: answer });
} else if (payload.type === 'webrtc-answer') {
await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
for (const candidate of pendingCandidates.current) {
await pc.addIceCandidate(candidate).catch(console.error);
}
pendingCandidates.current =[];
onConnected();
} else if (payload.type === 'webrtc-ice-candidate') {
const candidate = new RTCIceCandidate(payload.candidate);
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(candidate).catch(console.error);
} else {
pendingCandidates.current.push(candidate);
}
} else if (payload.type === 'voice_activity') {
setRemoteVoiceActive(payload.state === 'speaking');
}
};
const drainPendingSignals = async () => {
if (isProcessingSignals.current) return;
isProcessingSignals.current = true;
while (pendingSignals.current.length > 0) {
const payload = pendingSignals.current.shift();
await processSignal(payload);
}
isProcessingSignals.current = false;
};
drainPendingSignalsRef.current = drainPendingSignals;
const handleSignal = async (peerKey, payload) => {
if (peerKey !== targetKey) return;
try {
if (!pcRef.current && payload.type !== 'voice_activity') {
pendingSignals.current.push(payload);
return;
}
if (pcRef.current) {
pendingSignals.current.push(payload);
await drainPendingSignals();
} else if (payload.type === 'voice_activity') {
setRemoteVoiceActive(payload.state === 'speaking');
}
} catch (err) {
console.error("Error handling WebRTC signal:", err);
}
};
network.addWebRTCListener(handleSignal);
return () => network.removeWebRTCListener(handleSignal);
}, [targetKey, onConnected]);
const toggleMute = () => {
if (localStreamRef.current) {
const audioTrack = localStreamRef.current.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
setIsMuted(!audioTrack.enabled);
}
}
};
const toggleVideo = async () => {
if (isVideoOn) {
if (localCameraStreamRef.current) {
const track = localCameraStreamRef.current.getVideoTracks()[0];
const sender = pcRef.current?.getSenders().find(s => s.track === track);
if (sender && pcRef.current) pcRef.current.removeTrack(sender);
track.stop();
localCameraStreamRef.current = null;
}
setIsVideoOn(false);
if (pcRef.current && pcRef.current.signalingState !== 'closed') {
const offer = await pcRef.current.createOffer();
await pcRef.current.setLocalDescription(offer);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
}
} else {
try {
const videoInputId = localStorage.getItem('pear_video_input');
const stream = await navigator.mediaDevices.getUserMedia({
video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
});
localCameraStreamRef.current = stream;
const track = stream.getVideoTracks()[0];
if (pcRef.current) {
pcRef.current.addTrack(track, stream);
const offer = await pcRef.current.createOffer();
await pcRef.current.setLocalDescription(offer);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
}
setIsVideoOn(true);
} catch (err) {
console.error("Failed to start video", err);
}
}
};
const startScreenShare = async (sourceId, res, fps) => {
setShowScreenShareModal(false);
let stream = null;
try {
if (sourceId === 'native') {
stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
});
} else {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxWidth: res.width,
maxHeight: res.height,
maxFrameRate: fps
}
}
});
} catch (initialErr) {
console.warn("Optimal capture rejected. Using fallback.", initialErr);
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId
}
}
});
}
}
const videoTrack = stream.getVideoTracks()[0];
videoTrack.contentHint = 'motion';
videoTrack.onended = () => {
stopScreenShare();
};
localScreenStreamRef.current = stream;
setIsScreenSharing(true);
const sender = pcRef.current.addTrack(videoTrack, stream);
try {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) {
params.encodings = [{}];
}
params.encodings[0].maxFramerate = fps;
let maxBitrate = 8000000;
if (res.height <= 720) maxBitrate = 4000000;
if (res.height <= 480) maxBitrate = 1500000;
if (res.height <= 360) maxBitrate = 800000;
params.encodings[0].maxBitrate = maxBitrate;
if ('degradationPreference' in params) {
params.degradationPreference = 'maintain-framerate';
}
await sender.setParameters(params);
} catch (paramErr) {
console.warn("Could not set sender parameters:", paramErr);
}
const offer = await pcRef.current.createOffer();
await pcRef.current.setLocalDescription(offer);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
} catch (err) {
console.error("Screen share failed or cancelled", err);
if (localVideoRef.current) localVideoRef.current.srcObject = null;
if (stream) stream.getTracks().forEach(t => { t.enabled = false; t.stop(); });
if (localScreenStreamRef.current) {
localScreenStreamRef.current.getTracks().forEach(t => { t.enabled = false; t.stop(); });
localScreenStreamRef.current = null;
}
setIsScreenSharing(false);
if (err.name !== 'NotAllowedError' && err.name !== 'AbortError') {
alert(`Could not capture this window/screen.\nError: ${err.name} - ${err.message}`);
}
}
};
const stopScreenShare = async () => {
if (localVideoRef.current) {
localVideoRef.current.srcObject = null;
}
let trackToRemove = null;
if (localScreenStreamRef.current) {
trackToRemove = localScreenStreamRef.current.getVideoTracks()[0];
localScreenStreamRef.current.getTracks().forEach(t => {
t.enabled = false;
t.stop();
});
localScreenStreamRef.current = null;
}
setIsScreenSharing(false);
setIsFullscreen(false);
if (pcRef.current && trackToRemove) {
const senders = pcRef.current.getSenders();
const videoSender = senders.find(s => s.track === trackToRemove);
if (videoSender) {
try {
pcRef.current.removeTrack(videoSender);
if (pcRef.current.signalingState !== 'closed') {
const offer = await pcRef.current.createOffer();
await pcRef.current.setLocalDescription(offer);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-offer', sdp: offer });
}
} catch (e) {
console.warn("Failed to negotiate track removal", e);
}
}
}
};
return (
<div className={`bg-base flex flex-col relative ${className}`}>
<audio ref={remoteAudioRef} autoPlay className="hidden" />
{/* Header */}
<div className="h-14 shadow-sm flex items-center px-4 border-b border-surface gap-2 shrink-0 bg-panel">
<span className="font-bold text-text">Call: {targetProfile.displayName}</span>
<span className="ml-2 text-xs font-bold uppercase tracking-widest text-muted flex items-center gap-1">
{status === 'ringing' ? (
<>
Ringing
<span className="flex gap-0.5 items-center mt-1">
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0s' }}></span>
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0.15s' }}></span>
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0.3s' }}></span>
</span>
</>
) : status === 'connecting' ? (
'Connecting...'
) : (
<span className="text-green-500">Connected</span>
)}
</span>
</div>
{/* Main Call Area */}
<div className={`flex-1 flex ${isVideoActive ? 'flex-col' : 'items-center justify-center'} gap-8 p-8 overflow-hidden relative`}>
{/* Video Area */}
{isVideoActive && (
<div
className={isFullscreen
? "fixed inset-0 z-50 bg-black flex items-center justify-center"
: "flex-1 w-full bg-black rounded-lg overflow-hidden relative shadow-lg border border-surface cursor-pointer group"
}
onClick={() => !isFullscreen && setIsFullscreen(true)}
>
{hasRemoteVideo && (
<video ref={remoteVideoRef} autoPlay playsInline className="w-full h-full object-contain" />
)}
{isLocalVideoActive && !hasRemoteVideo && (
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
)}
{/* Small PiP if both are sharing */}
{hasRemoteVideo && isLocalVideoActive && (
<div className={`absolute bottom-4 right-4 aspect-video bg-black rounded border-2 border-surface overflow-hidden shadow-xl ${isFullscreen ? 'w-80' : 'w-48'}`}>
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-cover" />
</div>
)}
{!isFullscreen && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-white font-bold bg-black/50 px-4 py-2 rounded-full backdrop-blur-sm flex items-center gap-2">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>
Click to Enlarge
</span>
</div>
)}
{isFullscreen && (
<button
onClick={(e) => { e.stopPropagation(); setIsFullscreen(false); }}
className="absolute top-6 right-6 bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-full font-bold shadow-lg transition-colors z-50 flex items-center gap-2"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"></path></svg>
Exit Fullscreen
</button>
)}
</div>
)}
{/* User Squares Grid */}
<div className={`flex justify-center gap-6 ${isVideoActive ? 'shrink-0 h-40' : 'w-full max-w-3xl'}`}>
{/* Remote User Square */}
<div className={`bg-surface rounded-xl flex flex-col items-center justify-center gap-3 transition-all duration-300 shadow-lg border border-panel relative overflow-hidden ${isVideoActive ? 'w-48 h-full' : 'w-72 h-72'} ${status === 'ringing' ? 'opacity-50' : ''} ${remoteVoiceActive ? 'ring-2 ring-green-500' : 'ring-2 ring-transparent'}`}>
{status === 'ringing' && (
<div className="absolute inset-0 bg-black/20 flex items-center justify-center z-10">
<div className="w-full h-full animate-pulse bg-white/5"></div>
</div>
)}
<div className={`rounded-md flex items-center justify-center text-white font-bold overflow-hidden ${isVideoActive ? 'w-16 h-16 text-2xl' : 'w-28 h-28 text-4xl'} ${targetProfile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{targetProfile.avatar ? (
<img src={targetProfile.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
targetProfile.displayName?.substring(0, 2).toUpperCase() || '?'
)}
</div>
<span className={`text-text font-bold ${isVideoActive ? 'text-sm' : 'text-lg'}`}>{targetProfile.displayName || 'Unknown'}</span>
</div>
{/* Local User Square */}
<div className={`bg-surface rounded-xl flex flex-col items-center justify-center gap-3 transition-all duration-300 shadow-lg border border-panel ${isVideoActive ? 'w-48 h-full' : 'w-72 h-72'} ${localVoiceActive && !isMuted ? 'ring-2 ring-green-500' : 'ring-2 ring-transparent'}`}>
<div className={`rounded-md flex items-center justify-center text-white font-bold overflow-hidden ${isVideoActive ? 'w-16 h-16 text-2xl' : 'w-28 h-28 text-4xl'} ${myProfile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{myProfile.avatar ? (
<img src={myProfile.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
myProfile.displayName?.substring(0, 2).toUpperCase() || '?'
)}
</div>
<span className={`text-text font-bold ${isVideoActive ? 'text-sm' : 'text-lg'}`}>{myProfile.displayName} (You)</span>
</div>
</div>
</div>
{/* Bottom Controls */}
<div className="h-20 bg-surface flex items-center justify-center gap-4 shrink-0 rounded-t-2xl mx-4 border-t border-x border-panel">
<button
onClick={toggleMute}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-panel text-text hover:bg-base'}`}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
)}
</button>
<button
onClick={toggleVideo}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${!isVideoOn ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-panel text-text hover:bg-base'}`}
title={isVideoOn ? "Turn Off Camera" : "Turn On Camera"}
>
{isVideoOn ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
)}
</button>
<button
onClick={onToggleChat}
className="px-6 h-10 rounded bg-panel hover:bg-base text-text font-medium flex items-center gap-2 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
Chat
</button>
{isScreenSharing ? (
<button
onClick={stopScreenShare}
className="px-6 h-10 rounded bg-red-500 hover:bg-red-600 text-white font-medium flex items-center gap-2 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line><line x1="1" y1="1" x2="23" y2="23"></line></svg>
Stop Sharing
</button>
) : (
<button
onClick={() => setShowScreenShareModal(true)}
disabled={status !== 'connected'}
className="px-6 h-10 rounded bg-panel hover:bg-base text-text font-medium flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Share Screen
</button>
)}
<button
onClick={onClose}
className="px-6 h-10 rounded bg-red-500 hover:bg-red-600 text-white font-medium flex items-center gap-2 transition-colors"
>
End Call
</button>
</div>
{showScreenShareModal && (
<ScreenShareModal
onClose={() => setShowScreenShareModal(false)}
onStart={startScreenShare}
/>
)}
</div>
);
}

View File

@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
import CreateChannelModal from './CreateChannelModal.jsx';
export default function ChannelList({ activeChannel, setActiveChannel, myKey, profile, unreadCounts, onOpenSettings, activeView, servers, onOpenInvite, onOpenServerSettings, isSyncing, onlinePeers, knownUsers, serverMembers, activeCall, onReturnToCall, vcStates, activeVc, onJoinVC, isNetworkOnline }) {
const activeServerObj = servers.find(s => s.topicHex === activeView);
const serverName = activeServerObj ? activeServerObj.name : 'Unknown Hub';
const isAdmin = activeServerObj?.owner === myKey;
const canInvite = isAdmin || activeServerObj?.allowAnyoneToInvite;
const currentMembers = new Set(serverMembers[activeView] ||[]);
if (activeServerObj) currentMembers.add(activeServerObj.owner);
const onlineServerPeers = onlinePeers.filter(p => p.key !== myKey && currentMembers.has(p.key));
const hasOnlinePeers = onlineServerPeers.length > 0;
const [isCreateChannelOpen, setIsCreateChannelOpen] = useState(false);
const [createChannelType, setCreateChannelType] = useState('text');
let syncText = 'Synced';
let syncColor = 'bg-green-500';
if (!hasOnlinePeers) {
syncText = 'Waiting for Peers';
syncColor = 'bg-gray-500';
} else if (isSyncing) {
syncText = 'Syncing...';
syncColor = 'bg-yellow-500 animate-pulse';
}
const textChannels = activeServerObj?.channels?.text || ['general-chat'];
const voiceChannels = activeServerObj?.channels?.voice || ['general-voice'];
const handleCreateChannel = (name, type) => {
const newChannels = {
text: [...textChannels],
voice: [...voiceChannels]
};
if (type === 'text' && !newChannels.text.includes(name)) newChannels.text.push(name);
if (type === 'voice' && !newChannels.voice.includes(name)) newChannels.voice.push(name);
network.updateServerSettings(activeView, activeServerObj.name, activeServerObj.icon, activeServerObj.allowAnyoneToInvite, newChannels);
setIsCreateChannelOpen(false);
};
const renderChannel = (id, name) => {
const isActive = activeChannel === id;
const networkId = `${activeView}-${id}`;
const unread = unreadCounts[networkId] || 0;
const hasUnread = unread > 0 && !isActive;
return (
<div
key={id}
onClick={() => setActiveChannel(id)}
className={`px-2 py-1.5 rounded cursor-pointer flex items-center justify-between group ${
isActive ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'
}`}
>
<div className="flex items-center gap-2">
<span className="text-muted text-xl">#</span>
<span className={hasUnread ? 'font-bold text-text' : ''}>{name}</span>
</div>
{hasUnread && (
<div className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full">
{unread > 99 ? '99+' : unread}
</div>
)}
</div>
);
};
const renderVoiceChannel = (id, name) => {
const isActive = activeVc?.channelId === id && activeVc?.serverId === activeView;
const vcPeers = vcStates[activeView]?.[id] || {};
const peerKeys = Object.keys(vcPeers);
return (
<div key={id} className="flex flex-col mt-1">
<div
onClick={() => onJoinVC(id)}
className={`px-2 py-1.5 rounded cursor-pointer flex items-center gap-2 group ${
isActive ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'
}`}
>
<span className="text-muted text-xl">🔊</span>
<span className={isActive ? 'font-bold text-text' : ''}>{name}</span>
</div>
{peerKeys.length > 0 && (
<div className="flex flex-col gap-0.5 mt-0.5 pl-6 pr-2">
{peerKeys.map(peerKey => {
const state = vcPeers[peerKey];
let peerProfile = knownUsers.find(u => u.key === peerKey);
if (peerKey === myKey) peerProfile = profile;
if (!peerProfile) return null;
return (
<div key={peerKey} className="flex items-center justify-between group hover:bg-panel/50 rounded px-2 py-1 cursor-pointer">
<div className="flex items-center gap-2 overflow-hidden min-w-0">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-white text-[10px] font-bold shrink-0 overflow-hidden ${peerProfile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{peerProfile.avatar ? <img src={peerProfile.avatar} className="w-full h-full object-cover" /> : peerProfile.displayName.substring(0, 2).toUpperCase()}
</div>
<span className="text-sm text-muted group-hover:text-text truncate">{peerProfile.displayName}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 text-muted ml-3">
{state.screenshare && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
)}
{state.muted && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
return (
<div className="w-60 bg-surface flex flex-col shrink-0 border-r border-base">
{activeCall && (
<div
onClick={onReturnToCall}
className="bg-accent hover:opacity-90 text-white text-xs font-bold p-2 cursor-pointer flex items-center justify-center gap-2 transition-opacity shrink-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
Return to Call
</div>
)}
<div className="h-14 shadow-sm flex flex-col justify-center px-4 border-b border-base truncate hover:bg-panel transition-colors cursor-pointer group shrink-0">
<div className="flex items-center justify-between">
<span className="font-bold text-text truncate">{serverName}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted group-hover:text-text transition-colors shrink-0">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${syncColor}`}></div>
<span className="text-[9px] font-bold text-muted uppercase tracking-wider">{syncText}</span>
</div>
</div>
<div className="flex-1 p-2 space-y-1 overflow-y-auto">
<div className="flex flex-col gap-1 mb-2 border-b border-base pb-2">
{canInvite && (
<button onClick={onOpenInvite} className="w-full text-left px-2 py-1.5 text-sm text-accent hover:bg-accent/10 rounded transition-colors flex items-center justify-between">
Invite Contacts
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</button>
)}
{isAdmin && (
<button onClick={onOpenServerSettings} className="w-full text-left px-2 py-1.5 text-sm text-muted hover:bg-panel hover:text-text rounded transition-colors flex items-center justify-between">
Hub Settings
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</button>
)}
</div>
<div className="px-2 py-1 text-xs font-bold text-muted uppercase mt-2 flex justify-between items-center">
<span>Text Rooms</span>
{isAdmin && <button onClick={() => { setCreateChannelType('text'); setIsCreateChannelOpen(true); }} className="hover:text-text" title="Create Text Channel">+</button>}
</div>
{textChannels.map(ch => renderChannel(ch, ch))}
<div className="px-2 py-1 mt-4 text-xs font-bold text-muted uppercase flex justify-between items-center">
<span>Voice Rooms</span>
{isAdmin && <button onClick={() => { setCreateChannelType('voice'); setIsCreateChannelOpen(true); }} className="hover:text-text" title="Create Voice Channel">+</button>}
</div>
{voiceChannels.map(ch => renderVoiceChannel(ch, ch))}
</div>
<div
className="h-16 bg-panel flex items-center px-3 gap-3 shrink-0 cursor-pointer hover:bg-surface transition-colors border-t border-base"
onClick={onOpenSettings}
>
<div className="relative shrink-0 w-10 h-10">
<div className={`w-full h-full rounded-md flex items-center justify-center text-white text-sm font-bold overflow-hidden ${profile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{profile.avatar ? (
<img src={profile.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
profile.displayName.substring(0, 2).toUpperCase()
)}
</div>
<div className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[3px] border-panel ${isNetworkOnline ? 'bg-green-500' : 'bg-red-500'}`} title={isNetworkOnline ? "Online" : "Offline"}></div>
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-bold text-text leading-tight truncate flex items-center gap-1">
{profile.displayName}
{myKey === ADMIN_PUBLIC_KEY && <span title="Platform Admin">👑</span>}
</span>
<span className="text-[10px] text-muted leading-tight truncate">@{profile.username}</span>
</div>
</div>
{isCreateChannelOpen && (
<CreateChannelModal
onClose={() => setIsCreateChannelOpen(false)}
onSave={handleCreateChannel}
defaultType={createChannelType}
/>
)}
</div>
);
}

View File

@ -0,0 +1,815 @@
import React, { useState, useRef, useEffect } from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
import ServerInviteCard from './ServerInviteCard.jsx';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
const MarkdownComponents = {
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
className="rounded-md !my-2 !bg-base border border-surface text-sm custom-scrollbar"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className="bg-base text-text px-1.5 py-0.5 rounded font-mono text-[13px] before:content-none after:content-none" {...props}>
{children}
</code>
)
},
a: ({node, ...props}) => <a className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer" {...props} />,
};
const formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes =['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
};
// --- LIVE DECRYPTION ANIMATION SETTINGS ---
const DECRYPTION_INITIAL_DELAY_MS = 400; // Pause before decryption starts (ms)
const DECRYPTION_SPEED_MS = 30; // Speed of decryption per tick (ms)
const DECRYPTION_CHARS_PER_TICK = 1; // Number of characters decrypted per tick
// Cinematic Live Decryption Component
const DecryptedMessage = ({ msg, liveDecryption, animationTrigger }) => {
const [isAnimating, setIsAnimating] = useState(false);
const [revealed, setRevealed] = useState(0);
const [gibberish, setGibberish] = useState('');
const lastTrigger = useRef(animationTrigger);
useEffect(() => {
if (!liveDecryption || !msg.isEncrypted || !msg.text) {
setIsAnimating(false);
return;
}
const isNewTrigger = animationTrigger !== lastTrigger.current;
lastTrigger.current = animationTrigger;
const cacheKey = `animated_${msg.id}`;
if (!isNewTrigger && sessionStorage.getItem(cacheKey)) {
setIsAnimating(false);
return;
}
sessionStorage.setItem(cacheKey, 'true');
const text = msg.text;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
let gib = '';
let seed = 0;
const cipher = msg.cipher || 'fallback';
for(let i=0; i<cipher.length; i++) seed += cipher.charCodeAt(i);
for(let i=0; i<text.length; i++) {
const rand = Math.abs(Math.sin(seed + i) * 10000);
gib += chars[Math.floor(rand * chars.length) % chars.length];
}
setGibberish(gib);
setRevealed(0);
setIsAnimating(true);
let curr = 0;
let interval;
// Initial pause to show the fully encrypted message
const timeout = setTimeout(() => {
// Start the character-by-character decryption wave
interval = setInterval(() => {
curr += DECRYPTION_CHARS_PER_TICK;
if (curr >= text.length) {
setRevealed(text.length);
setIsAnimating(false);
clearInterval(interval);
} else {
setRevealed(curr);
}
}, DECRYPTION_SPEED_MS);
}, DECRYPTION_INITIAL_DELAY_MS);
return () => {
clearTimeout(timeout);
if (interval) clearInterval(interval);
};
}, [msg.text, msg.cipher, msg.isEncrypted, liveDecryption, msg.id, animationTrigger]);
if (!isAnimating) {
return (
<div className="prose prose-invert max-w-none text-[15px] prose-p:leading-relaxed prose-pre:p-0 prose-pre:bg-transparent prose-a:text-blue-400 hover:prose-a:text-blue-300 prose-img:rounded-md prose-img:max-h-96 prose-img:object-contain prose-headings:my-2 prose-h1:text-2xl prose-h1:font-bold prose-h2:text-xl prose-h2:font-bold prose-h3:text-lg prose-h3:font-bold prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 break-words">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} components={MarkdownComponents}>
{msg.text || ''}
</ReactMarkdown>
</div>
);
}
return (
<div className="font-mono text-[15px] whitespace-pre-wrap break-words leading-relaxed">
<span>{msg.text.substring(0, revealed)}</span>
<span className="bg-green-500/20 text-green-400 rounded px-0.5">{gibberish.substring(revealed)}</span>
</div>
);
};
export default function ChatArea({ activeView, activeChannel, messages, myKey, profile, typingUsers, readReceipts, deliveredReceipts, onlinePeers, markChannelRead, dms, servers, onStartCall, activeCall, onReturnToCall, transfers, onOpenInvite, onToggleMembers }) {
const[inputText, setInputText] = useState('');
const[editingId, setEditingId] = useState(null);
const[editInput, setEditInput] = useState('');
const[activeTypers, setActiveTypers] = useState([]);
const [attachments, setAttachments] = useState([]);
const[isDragging, setIsDragging] = useState(false);
const[expandedImage, setExpandedImage] = useState(null);
const [contextMenu, setContextMenu] = useState(null);
// Visual Decryption State
const [showCrypto, setShowCrypto] = useState(false);
const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
const [animationTrigger, setAnimationTrigger] = useState(0);
const messagesEndRef = useRef(null);
const chatContainerRef = useRef(null);
const textareaRef = useRef(null);
const editTextareaRef = useRef(null);
const fileInputRef = useRef(null);
const lastTypingTime = useRef(0);
const isDMView = activeView === 'dms';
const gcObj = isDMView ? servers.find(s => s.topicHex === activeChannel && s.isGroupChat) : null;
const isGroupChat = !!gcObj;
const networkChannelId = isGroupChat ? activeChannel : (isDMView ? activeChannel : `${activeView}-${activeChannel}`);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
},[activeChannel, activeView]);
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [contextMenu]);
useEffect(() => {
if (chatContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 200;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
const sendRead = () => {
const currentChannelMessages = messages.filter(m => {
if (isDMView && !isGroupChat) return (m.sender === myKey && m.recipient === activeChannel) || (m.sender === activeChannel && m.recipient === myKey);
return m.channel === networkChannelId && !m.recipient;
});
const latestMsg = currentChannelMessages[currentChannelMessages.length - 1];
const latestMsgId = latestMsg ? latestMsg.id : null;
if (latestMsgId) network.sendReadReceipt(networkChannelId, latestMsgId);
markChannelRead(networkChannelId);
};
sendRead();
const interval = setInterval(sendRead, 3000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
},[messages.length, activeChannel, isDMView, isGroupChat, myKey, onlinePeers.length, activeView]);
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
const typers = Object.entries(typingUsers)
.filter(([key, info]) => {
if (now - info.timestamp >= 3000 || key === myKey) return false;
return (isDMView && !isGroupChat) ? (key === activeChannel && info.channel === myKey) : (info.channel === networkChannelId);
})
.map(([_, info]) => info.displayName);
setActiveTypers(typers);
}, 1000);
return () => clearInterval(interval);
},[typingUsers, activeChannel, myKey, isDMView, isGroupChat, networkChannelId]);
useEffect(() => {
if (editingId && editTextareaRef.current) {
editTextareaRef.current.style.height = 'auto';
editTextareaRef.current.style.height = `${Math.min(editTextareaRef.current.scrollHeight, 400)}px`;
editTextareaRef.current.focus();
}
},[editingId]);
const processFiles = async (files) => {
const newAttachments =[];
for (let i = 0; i < files.length; i++) {
const file = files[i];
newAttachments.push({
name: file.name,
size: file.size,
type: file.type,
path: file.path,
fileObj: file,
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null
});
}
setAttachments(prev =>[...prev, ...newAttachments]);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
processFiles(e.dataTransfer.files);
}
};
const handlePaste = (e) => {
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
processFiles(e.clipboardData.files);
}
};
const handleInputChange = (e) => {
setInputText(e.target.value);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 400)}px`;
}
const now = Date.now();
if (now - lastTypingTime.current > 2000) {
network.sendTyping(networkChannelId);
lastTypingTime.current = now;
}
};
const handleSendMessage = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (attachments.length > 0) {
for (let i = 0; i < attachments.length; i++) {
const textToSend = i === 0 ? inputText.trim() : '';
if (isDMView && !isGroupChat) network.sendDMFile(activeChannel, textToSend, attachments[i]);
else network.sendFile(networkChannelId, textToSend, attachments[i]);
}
setAttachments([]);
setInputText('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
} else if (inputText.trim() !== '') {
if (isDMView && !isGroupChat) network.sendDM(activeChannel, inputText.trim());
else network.sendMessage(networkChannelId, inputText.trim());
setInputText('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}
}
};
const startEditing = (msg) => {
setEditingId(msg.id);
setEditInput(msg.text);
};
const handleEditChange = (e) => {
setEditInput(e.target.value);
if (editTextareaRef.current) {
editTextareaRef.current.style.height = 'auto';
editTextareaRef.current.style.height = `${Math.min(editTextareaRef.current.scrollHeight, 400)}px`;
}
};
const handleEditMessage = (e, id) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editInput.trim() !== '') network.sendEditMessage(id, editInput.trim());
setEditingId(null);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const handleOpenFolder = async (filePath) => {
if (!filePath) return;
try {
if (typeof Pear !== 'undefined') {
const { spawn } = await import('child_process');
const os = await import('os');
const path = await import('path');
const platform = os.platform();
if (platform === 'win32') {
const child = spawn('explorer.exe',['/select,', filePath], { detached: true });
child.unref();
} else if (platform === 'darwin') {
const child = spawn('open',['-R', filePath], { detached: true });
child.unref();
} else {
const dir = path.dirname(filePath);
const child = spawn('xdg-open',[dir], { detached: true });
child.unref();
}
} else if (typeof window !== 'undefined' && window.require) {
const { shell } = window.require('electron');
shell.showItemInFolder(filePath);
}
} catch (err) {
console.error("Failed to open folder:", err.message || err);
}
};
const handleToggleLiveDecryption = () => {
const newVal = !liveDecryption;
setLiveDecryption(newVal);
localStorage.setItem('pear_live_decryption', newVal);
if (newVal) {
setAnimationTrigger(Date.now()); // Force re-animation wave
}
};
const currentChannelMessages = messages.filter(m => {
if (isDMView && !isGroupChat) return (m.sender === myKey && m.recipient === activeChannel) || (m.sender === activeChannel && m.recipient === myKey);
return m.channel === networkChannelId && !m.recipient;
});
const myMessages = currentChannelMessages.filter(m => m.sender === myKey);
const lastMyMessageId = myMessages.length > 0 ? myMessages[myMessages.length - 1].id : null;
const readMsgId = readReceipts[networkChannelId];
const explicitDeliveredMsgId = deliveredReceipts ? deliveredReceipts[networkChannelId] : null;
const readMsgIndex = currentChannelMessages.findIndex(m => m.id === readMsgId);
const explicitDeliveredMsgIndex = currentChannelMessages.findIndex(m => m.id === explicitDeliveredMsgId);
const isPeerOnline = (isDMView && !isGroupChat) ? onlinePeers.some(p => p.key === activeChannel) : onlinePeers.length > 0;
let effectiveDeliveredMsgIndex = explicitDeliveredMsgIndex;
if (isPeerOnline && isDMView && !isGroupChat) effectiveDeliveredMsgIndex = currentChannelMessages.length - 1;
let lastMyReadMsgId = null;
let lastMyDeliveredMsgId = null;
for (let i = currentChannelMessages.length - 1; i >= 0; i--) {
const m = currentChannelMessages[i];
if (m.sender === myKey) {
if (!lastMyDeliveredMsgId && effectiveDeliveredMsgIndex !== -1 && i <= effectiveDeliveredMsgIndex) lastMyDeliveredMsgId = m.id;
if (!lastMyReadMsgId && readMsgIndex !== -1 && i <= readMsgIndex) lastMyReadMsgId = m.id;
}
}
const getMessageStatus = (msg) => {
if (msg.sender !== myKey) return null;
const msgIndex = currentChannelMessages.findIndex(m => m.id === msg.id);
if (isDMView && !isGroupChat) {
if (msg.id === lastMyReadMsgId) {
const hasNewerReply = currentChannelMessages.slice(msgIndex + 1).some(m => m.sender !== myKey);
if (!hasNewerReply) {
const targetProfile = dms[activeChannel]?.profile || {};
return (
<div className="w-[14px] h-[14px] rounded-full overflow-hidden inline-block ml-2 align-middle bg-surface border border-panel" title="Read">
{targetProfile.avatar ? (
<img src={targetProfile.avatar} className="w-full h-full object-cover rounded-full" />
) : (
<div className="w-full h-full bg-indigo-500 rounded-full flex items-center justify-center text-[6px] text-white font-bold">
{targetProfile.displayName?.substring(0, 2).toUpperCase() || '?'}
</div>
)}
</div>
);
}
}
const isAfterRead = readMsgIndex === -1 || msgIndex > readMsgIndex;
if (isAfterRead) {
const isDelivered = effectiveDeliveredMsgIndex !== -1 && msgIndex <= effectiveDeliveredMsgIndex;
if (isDelivered) return <span className="text-blue-400 text-[10px] ml-2 uppercase font-bold tracking-wider" title="Delivered"></span>;
else return <span className="text-muted text-[10px] ml-2 uppercase font-bold tracking-wider" title="Sent"></span>;
}
return null;
} else {
if (msg.id !== lastMyMessageId) return null;
const isRead = readMsgIndex !== -1 && msgIndex <= readMsgIndex;
if (isRead) return <span className="text-blue-400 ml-2 text-xs" title="Read"></span>;
if (isPeerOnline) return <span className="text-muted ml-2 text-xs" title="Delivered"></span>;
return <span className="text-muted/50 ml-2 text-xs" title="Sent"></span>;
}
};
let isAdmin = myKey === ADMIN_PUBLIC_KEY;
if (!isDMView || isGroupChat) {
const activeServerObj = servers.find(s => s.topicHex === (isGroupChat ? activeChannel : activeView));
if (activeServerObj && activeServerObj.owner === myKey) isAdmin = true;
}
const canPost = true;
const headerName = isGroupChat ? gcObj.name : (isDMView ? (dms[activeChannel]?.profile?.displayName || 'Unknown') : activeChannel);
const headerIcon = isGroupChat ? '👥' : (isDMView ? '@' : '#');
let typingText = '';
if (activeTypers.length === 1) typingText = `${activeTypers[0]} is typing...`;
else if (activeTypers.length > 1) typingText = `Several people are typing...`;
const isCallActiveInThisDM = activeCall && activeCall.targetKey === activeChannel;
return (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className="flex-1 flex flex-col bg-panel min-w-0 relative h-full"
>
{isDragging && (
<div className="absolute inset-0 z-50 bg-accent/90 flex items-center justify-center backdrop-blur-sm m-4 rounded-xl border-2 border-dashed border-white pointer-events-none">
<div className="text-center text-white">
<svg className="w-20 h-20 mx-auto mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
<h2 className="text-3xl font-bold">Drop files to upload</h2>
</div>
</div>
)}
{expandedImage && (
<div className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center backdrop-blur-sm" onClick={() => setExpandedImage(null)}>
<img src={expandedImage} className="max-w-[90vw] max-h-[90vh] object-contain" onClick={(e) => e.stopPropagation()} />
<button className="absolute top-6 right-6 text-white hover:text-gray-300" onClick={() => setExpandedImage(null)}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
)}
{contextMenu && (
<div className="fixed inset-0 z-50" onClick={() => setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
<div
className="absolute bg-panel border border-surface shadow-xl rounded py-1.5 w-40 flex flex-col"
style={{
top: Math.min(contextMenu.y, window.innerHeight - 100),
left: Math.min(contextMenu.x, window.innerWidth - 160)
}}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.isMe && contextMenu.msg.payload?.type !== 'server_invite' && (
<button
className="w-full text-left px-3 py-1.5 text-sm text-text hover:bg-accent hover:text-white transition-colors"
onClick={() => {
startEditing(contextMenu.msg);
setContextMenu(null);
}}
>
Edit Message
</button>
)}
{(contextMenu.isAdmin || contextMenu.isMe) && (
<button
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
onClick={() => {
network.sendDeleteMessage(contextMenu.msg.id);
setContextMenu(null);
}}
>
Delete Message
</button>
)}
</div>
</div>
)}
<div className="h-14 shadow-sm flex items-center px-4 border-b border-base gap-3 shrink-0 bg-panel z-10">
<span className="text-muted text-2xl">{headerIcon}</span>
<span className="font-bold text-text">{headerName}</span>
<div className="ml-auto flex items-center gap-2">
{isDMView && !isGroupChat && (
<>
<button
onClick={handleToggleLiveDecryption}
className={`p-2 rounded transition-colors flex items-center gap-2 text-xs font-bold ${liveDecryption ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
title="Toggle Live Decryption Animation"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12h4l3-9 5 18 3-9h5"></path></svg>
{liveDecryption ? 'Live Decryption ON' : 'Live Decryption OFF'}
</button>
<button
onClick={() => setShowCrypto(!showCrypto)}
className={`p-2 rounded transition-colors flex items-center gap-2 text-xs font-bold ${showCrypto ? 'bg-green-500/20 text-green-500' : 'text-muted hover:text-text'}`}
title="Toggle Developer Crypto Mode"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
{showCrypto ? 'E2EE Verified' : 'Verify E2EE'}
</button>
</>
)}
{(isDMView || isGroupChat) && (
<>
{isGroupChat && (
<button onClick={() => onOpenInvite(activeChannel)} className="text-muted hover:text-text p-2 transition-colors" title="Add Contacts">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5c-2.2 0-4 1.8-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
</button>
)}
{isCallActiveInThisDM ? (
<button
onClick={onReturnToCall}
className="bg-accent hover:opacity-90 text-white px-3 py-1 rounded text-sm font-bold transition-opacity flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
Return to Call
</button>
) : (
<>
<button
onClick={() => onStartCall(activeChannel, 'voice')}
className="text-muted hover:text-text p-2 transition-colors"
title="Start Voice Call"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
</button>
<button
onClick={() => onStartCall(activeChannel, 'video')}
className="text-muted hover:text-text p-2 transition-colors"
title="Start Video Call"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
</button>
</>
)}
</>
)}
{(!isDMView || isGroupChat) && (
<button onClick={onToggleMembers} className="text-muted hover:text-text p-2 transition-colors" title="Toggle Members">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
</button>
)}
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto flex flex-col" ref={chatContainerRef}>
<div className="mt-auto"></div>
<div className="text-center my-8">
<div className="w-20 h-20 bg-surface rounded-full mx-auto mb-4 flex items-center justify-center text-4xl text-text">{headerIcon}</div>
<h1 className="text-3xl font-bold text-text mb-2">{isGroupChat ? `Welcome to ${headerName}!` : (isDMView ? headerName : `Welcome to #${headerName}!`)}</h1>
<p className="text-muted">
{isGroupChat ? `This is the beginning of your group whisper history.` : (isDMView ? `This is the beginning of your whisper history with ${headerName}.` : `This is the start of the decentralized #${headerName} room.`)}
</p>
</div>
{currentChannelMessages.map((msg) => {
const isPlatformAdmin = msg.sender === ADMIN_PUBLIC_KEY;
const isServerOwner = (!isDMView || isGroupChat) && servers.find(s=>s.topicHex===(isGroupChat ? activeChannel : activeView))?.owner === msg.sender;
const showCrown = isPlatformAdmin || isServerOwner;
const crownTitle = isServerOwner ? (isGroupChat ? "Group Creator" : "Hub Owner") : "Platform Admin";
const isMe = msg.sender === myKey;
return (
<div
key={msg.id}
className="flex gap-4 w-full hover:bg-panel/40 px-4 py-2 mt-1 group/msg relative transition-colors"
onContextMenu={(e) => {
if (isMe || isAdmin) {
e.preventDefault();
setContextMenu({ x: e.pageX, y: e.pageY, msg, isMe, isAdmin });
}
}}
>
<div className={`w-10 h-10 rounded-full shrink-0 flex items-center justify-center text-white font-bold overflow-hidden mt-0.5 ${msg.senderAvatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{msg.senderAvatar ? <img src={msg.senderAvatar} className="w-full h-full object-cover" /> : msg.senderName.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-baseline gap-2 mb-1">
<span className="font-medium text-text flex items-center gap-1">
{isMe ? `${profile.displayName} (You)` : msg.senderName}
{showCrown && <span title={crownTitle} className="text-yellow-500 text-xs">👑</span>}
</span>
<span className="text-xs text-muted">{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
{getMessageStatus(msg)}
</div>
<div className="text-text text-[15px] leading-relaxed">
{showCrypto && msg.isEncrypted ? (
<div className="bg-black/50 border border-green-500/30 p-3 rounded-md font-mono text-[11px] text-green-400 break-all my-1">
<div className="text-green-500/50 mb-1 select-none">Algorithm: xchacha20poly1305_ietf</div>
<div className="text-green-500/50 mb-1 select-none">Nonce: {msg.nonce}</div>
<div className="select-all">{msg.cipher}</div>
</div>
) : editingId === msg.id ? (
<div className="mt-1 min-w-[200px]">
<textarea
ref={editTextareaRef}
value={editInput}
onChange={handleEditChange}
onKeyDown={(e) => handleEditMessage(e, msg.id)}
className="w-full bg-base text-text rounded p-2 outline-none resize-none max-h-[50vh] custom-scrollbar border border-surface"
rows={1}
/>
<span className="text-[10px] text-muted mt-1 block">escape to cancel enter to save shift+enter for new line</span>
</div>
) : msg.payload?.type === 'server_invite' ? (
<div className="mt-1 mb-1">
<ServerInviteCard invite={msg.payload} joinedServers={servers} />
</div>
) : msg.payload?.type === 'file' ? (
<div className="flex flex-col w-full min-w-0">
{msg.text && (
<DecryptedMessage msg={msg} liveDecryption={liveDecryption} animationTrigger={animationTrigger} />
)}
{(() => {
const fileMeta = msg.payload.file;
const transfer = transfers[msg.id];
const isImage = fileMeta.mimeType?.startsWith('image/');
const isVideo = fileMeta.mimeType?.startsWith('video/');
const isComplete = !!msg.localPath || !!msg.localBlobUrl;
const isSender = msg.sender === myKey;
const stateText = transfer ? (
transfer.state === 'processing' ? 'Processing Local File...' :
transfer.state === 'uploading' ? 'Uploading to Peer...' :
transfer.state === 'downloading' ? 'Downloading...' : 'Complete'
) : 'Waiting for peer...';
if (!isComplete) {
return (
<div className="bg-panel p-4 rounded-lg border border-surface w-80 mt-1">
<div className="flex justify-between text-xs text-muted mb-2">
<span className="font-bold text-text truncate pr-2">{fileMeta.name}</span>
<span>{transfer ? Math.round(transfer.progress * 100) + '%' : '0%'}</span>
</div>
<div className="w-full bg-base rounded-full h-2 mb-2 overflow-hidden">
<div className="bg-accent h-2 rounded-full transition-all duration-300" style={{ width: `${transfer ? transfer.progress * 100 : 0}%` }}></div>
</div>
<div className="flex justify-between text-[10px] text-muted items-center">
<span>{formatBytes(fileMeta.size)}</span>
<div className="flex items-center gap-2">
<span>{stateText}</span>
{transfer && transfer.state !== 'completed' && transfer.state !== 'processing' && (
<span className="text-muted/70"> {formatBytes(transfer.speed)}/s</span>
)}
</div>
</div>
</div>
);
} else {
if (isImage || isVideo) {
const fileUrl = msg.localBlobUrl ? msg.localBlobUrl : `peercord://local/${encodeURIComponent(msg.localPath.replace(/\\/g, '/'))}`;
return (
<div className="mt-1 flex flex-col gap-1">
{isImage && (
<img src={fileUrl} alt={fileMeta.name} className="max-w-sm max-h-80 rounded-lg object-contain cursor-pointer border border-surface bg-base" onClick={() => setExpandedImage(fileUrl)} />
)}
{isVideo && (
<video
src={fileUrl}
controls
preload="metadata"
className="max-w-md max-h-96 rounded-lg border border-surface bg-black"
/>
)}
{transfer && transfer.state === 'uploading' && transfer.progress < 1 && (
<div className="flex items-center gap-2 text-[10px] text-muted mt-1 bg-panel p-2 rounded w-fit border border-surface">
<div className="w-24 bg-base rounded-full h-1.5 overflow-hidden">
<div className="bg-accent h-1.5 rounded-full transition-all duration-300" style={{ width: `${transfer.progress * 100}%` }}></div>
</div>
<span>{transfer.progress > 0 ? `Uploading to peer... ${Math.round(transfer.progress * 100)}% • ${formatBytes(transfer.speed)}/s` : 'Seeding file...'}</span>
</div>
)}
</div>
);
} else {
if (isSender) {
return (
<div className="mt-1 flex flex-col gap-1">
<div className="bg-panel p-3 rounded-lg border border-surface flex items-center gap-3 w-80">
<div className="w-10 h-10 bg-base rounded flex items-center justify-center text-text shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-bold text-text truncate">{fileMeta.name}</span>
<span className="text-xs text-muted">{formatBytes(fileMeta.size)} Sent File</span>
</div>
</div>
{transfer && transfer.state === 'uploading' && transfer.progress < 1 && (
<div className="flex items-center gap-2 text-[10px] text-muted mt-1 bg-panel p-2 rounded w-fit border border-surface">
<div className="w-24 bg-base rounded-full h-1.5 overflow-hidden">
<div className="bg-accent h-1.5 rounded-full transition-all duration-300" style={{ width: `${transfer.progress * 100}%` }}></div>
</div>
<span>{transfer.progress > 0 ? `Uploading to peer... ${Math.round(transfer.progress * 100)}% • ${formatBytes(transfer.speed)}/s` : 'Seeding file...'}</span>
</div>
)}
</div>
);
} else {
return (
<div className="mt-1 flex flex-col gap-1">
<div className="bg-panel p-3 rounded-lg border border-surface flex items-center gap-3 w-80 cursor-pointer hover:bg-base transition-colors" onClick={() => handleOpenFolder(msg.localPath)}>
<div className="w-10 h-10 bg-base rounded flex items-center justify-center text-text shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-bold text-text truncate">{fileMeta.name}</span>
<span className="text-xs text-muted">{formatBytes(fileMeta.size)} Click to show in folder</span>
</div>
</div>
</div>
);
}
}
}
})()}
</div>
) : (
<div className="flex flex-col w-full min-w-0">
<DecryptedMessage msg={msg} liveDecryption={liveDecryption} animationTrigger={animationTrigger} />
</div>
)}
{msg.edited && !showCrypto && <span className="text-[10px] text-muted ml-2">(edited)</span>}
</div>
</div>
{/* Actions */}
<div className="absolute right-4 top-2 opacity-0 group-hover/msg:opacity-100 flex items-center bg-surface border border-panel rounded-md shadow-sm z-10 overflow-hidden">
{isMe && msg.payload?.type !== 'server_invite' && (
<button onClick={() => startEditing(msg)} className="text-muted hover:text-text hover:bg-panel p-2 transition-colors" title="Edit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
)}
{(isAdmin || isMe) && (
<button onClick={() => network.sendDeleteMessage(msg.id)} className="text-red-500 hover:text-red-400 hover:bg-panel p-2 transition-colors" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
)}
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="px-4 pb-4 pt-1 shrink-0 relative">
<div className="absolute -top-6 left-4 text-xs font-medium text-muted flex items-center gap-1.5 h-6">
{typingText && (
<><span className="flex gap-1 items-center mr-1"><span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0s' }}></span><span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0.15s' }}></span><span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0.3s' }}></span></span>{typingText}</>
)}
</div>
{attachments.length > 0 && (
<div className="bg-surface p-4 rounded-t-lg border-b border-base flex gap-4 overflow-x-auto custom-scrollbar mx-0 mt-2">
{attachments.map((att, i) => (
<div key={i} className="relative w-40 h-40 bg-base rounded-lg border border-panel flex flex-col items-center justify-center shrink-0 group">
<button onClick={() => setAttachments(prev => prev.filter((_, index) => index !== i))} className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-10">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
{att.preview ? (
<img src={att.preview} className="w-full h-full object-cover rounded-lg" />
) : (
<div className="text-muted flex flex-col items-center gap-2 p-2 text-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
<span className="text-xs font-bold truncate w-full">{att.name}</span>
</div>
)}
</div>
))}
</div>
)}
<div className={`bg-surface p-3 flex items-center gap-3 relative ${attachments.length > 0 ? 'rounded-b-lg' : 'rounded-lg'}`}>
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={(e) => processFiles(e.target.files)} />
<button
onClick={() => fileInputRef.current?.click()}
className="w-6 h-6 rounded-full bg-muted hover:bg-text text-base flex items-center justify-center shrink-0 transition-colors"
disabled={!canPost || !myKey}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<textarea
ref={textareaRef}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleSendMessage}
onPaste={handlePaste}
placeholder={canPost ? `Message ${isGroupChat ? '' : (isDMView ? '@' : '#')}${headerName}` : "Only Admins can post in this room"}
className="bg-transparent border-none outline-none text-text w-full disabled:opacity-50 resize-none max-h-[50vh] custom-scrollbar"
disabled={!canPost || !myKey}
rows={1}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import React, { useEffect, useRef, useState } from 'react';
export default function ConsoleOverlay({ logs, onClose }) {
const endRef = useRef(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
const handleCopy = () => {
const text = logs.map(l => `[${l.time}] ${l.msg}`).join('\n');
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="absolute inset-0 z-[9999] bg-black/90 flex flex-col font-mono text-xs text-gray-300 backdrop-blur-sm">
<div className="flex justify-between items-center bg-gray-900 p-3 border-b border-gray-700 shrink-0 shadow-lg">
<span className="font-bold text-white text-sm flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>
Developer Console (Press F10 to toggle)
</span>
<div className="flex items-center gap-3">
<button
onClick={handleCopy}
className="text-indigo-400 hover:text-indigo-300 hover:bg-indigo-400/10 px-3 py-1 rounded font-bold transition-colors flex items-center gap-2"
>
{copied ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Copied!
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
Copy Logs
</>
)}
</button>
<button onClick={onClose} className="text-red-400 hover:text-red-300 hover:bg-red-400/10 px-3 py-1 rounded font-bold transition-colors">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
{logs.length === 0 && (
<div className="text-gray-500 italic">No logs captured yet...</div>
)}
{logs.map((log, i) => (
<div key={i} className={`border-b border-gray-800/50 pb-1.5 pt-0.5 ${log.type === 'error' ? 'text-red-400 bg-red-500/5 px-2 rounded' : log.type === 'warn' ? 'text-yellow-400' : log.type === 'info' ? 'text-blue-400' : 'text-gray-300'}`}>
<span className="text-gray-500 mr-3 select-none">[{log.time}]</span>
<span className="whitespace-pre-wrap break-words">{log.msg}</span>
</div>
))}
<div ref={endRef} />
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
export default function CreateChannelModal({ onClose, onSave, defaultType = 'text' }) {
const [name, setName] = useState('');
const [type, setType] = useState(defaultType);
const handleSave = () => {
const cleanName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
if (cleanName) onSave(cleanName, type);
};
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-sm flex flex-col border border-panel" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-panel flex justify-between items-center">
<h2 className="text-lg font-bold text-text">Create Channel</h2>
<button onClick={onClose} className="text-muted hover:text-text transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div className="p-4 flex flex-col gap-4">
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Channel Type</label>
<div className="flex gap-2">
<button
onClick={() => setType('text')}
className={`flex-1 py-2 rounded text-sm font-bold transition-colors ${type === 'text' ? 'bg-accent text-white' : 'bg-panel text-muted hover:text-text'}`}
>
Text
</button>
<button
onClick={() => setType('voice')}
className={`flex-1 py-2 rounded text-sm font-bold transition-colors ${type === 'voice' ? 'bg-accent text-white' : 'bg-panel text-muted hover:text-text'}`}
>
Voice
</button>
</div>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Channel Name</label>
<div className="relative">
<span className="absolute left-3 top-2.5 text-muted font-bold">{type === 'text' ? '#' : '🔊'}</span>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
placeholder="new-channel"
className="w-full bg-panel text-text rounded p-2.5 pl-8 outline-none focus:ring-1 focus:ring-accent text-sm"
autoFocus
/>
</div>
</div>
</div>
<div className="p-4 bg-base rounded-b-lg flex justify-end gap-3 border-t border-panel">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">Cancel</button>
<button onClick={handleSave} disabled={!name.trim()} className="bg-accent hover:opacity-90 text-white px-6 py-2 rounded text-sm font-medium transition-opacity disabled:opacity-50">Create Channel</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
import React, { useState } from 'react';
export default function CreateGroupModal({ onClose, onSave, dms }) {
const[name, setName] = useState('');
const [selected, setSelected] = useState(new Set());
const[searchQuery, setSearchQuery] = useState('');
const friends = Object.entries(dms)
.filter(([_, data]) => data.status === 'accepted')
.filter(([_, data]) =>
data.profile?.displayName?.toLowerCase().includes(searchQuery.toLowerCase())
);
const toggleSelect = (friendKey) => {
const next = new Set(selected);
if (next.has(friendKey)) {
next.delete(friendKey);
} else {
if (next.size < 49) next.add(friendKey);
}
setSelected(next);
};
const handleSave = () => {
if (selected.size === 0) return;
let finalName = name.trim();
if (!finalName) {
finalName = Array.from(selected)
.map(k => dms[k].profile?.displayName || 'Unknown')
.slice(0, 4)
.join(', ');
}
onSave(finalName, Array.from(selected));
};
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-md flex flex-col border border-panel" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-panel flex flex-col gap-3">
<div className="flex justify-between items-center">
<h2 className="text-lg font-bold text-text">Create Group Whisper</h2>
<button onClick={onClose} className="text-muted hover:text-text transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-1">Group Name (Optional)</label>
<input
type="text"
placeholder="e.g. The Squad"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm mb-2"
maxLength={32}
/>
</div>
<div className="relative">
<input
type="text"
placeholder="Search for contacts"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-panel text-text rounded p-2 pl-8 outline-none focus:ring-1 focus:ring-accent text-sm"
/>
<svg className="absolute left-2.5 top-2.5 text-muted" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
</div>
<div className="p-4 pt-2">
<div className="max-h-64 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
{friends.length === 0 ? (
<div className="text-center text-muted py-4 text-sm">
{searchQuery ? "No contacts found matching that name." : "You don't have any contacts to add yet."}
</div>
) : (
friends.map(([friendKey, data]) => {
const isSelected = selected.has(friendKey);
return (
<div
key={friendKey}
onClick={() => toggleSelect(friendKey)}
className="flex items-center justify-between group hover:bg-panel p-2 rounded transition-colors cursor-pointer"
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${data.profile?.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{data.profile?.avatar ? <img src={data.profile?.avatar} className="w-full h-full object-cover" /> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<span className="text-text font-medium">{data.profile?.displayName}</span>
</div>
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${isSelected ? 'bg-accent border-accent' : 'border-muted group-hover:border-text'}`}>
{isSelected && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>}
</div>
</div>
);
})
)}
</div>
</div>
<div className="p-4 bg-base rounded-b-lg flex justify-between items-center border-t border-panel">
<span className="text-xs text-muted font-medium">{selected.size}/49 Selected</span>
<button
onClick={handleSave}
disabled={selected.size === 0}
className="bg-accent hover:opacity-90 text-white px-6 py-2 rounded text-sm font-medium transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Group Whisper
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
import React, { useState, useRef } from 'react';
export default function CreateServerModal({ onClose, onSave }) {
const [serverName, setServerName] = useState('');
const [serverIcon, setServerIcon] = useState(null);
const[allowAnyone, setAllowAnyone] = useState(true);
const fileInputRef = useRef(null);
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_SIZE = 128;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_SIZE) { height *= MAX_SIZE / width; width = MAX_SIZE; }
} else {
if (height > MAX_SIZE) { width *= MAX_SIZE / height; height = MAX_SIZE; }
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
setServerIcon(dataUrl);
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
const handleCreate = () => {
if (serverName.trim() === '') return;
onSave(serverName.trim(), serverIcon, allowAnyone);
};
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-md flex flex-col p-6 gap-4 border border-panel" onClick={e => e.stopPropagation()}>
<h2 className="text-2xl font-bold text-center text-text">Create Your Hub</h2>
<p className="text-sm text-center text-muted">
Give your new hub a personality with a name and an icon.
</p>
<div className="flex flex-col items-center gap-4 mt-4">
<div
className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold cursor-pointer relative group overflow-hidden shrink-0 border-2 border-dashed border-muted hover:border-text ${serverIcon ? 'bg-transparent border-solid' : 'bg-panel'}`}
onClick={() => fileInputRef.current?.click()}
>
{serverIcon ? (
<img src={serverIcon} alt="hub icon" className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs text-muted flex flex-col items-center gap-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
Upload
</div>
)}
<input type="file" ref={fileInputRef} onChange={handleImageUpload} accept="image/png, image/jpeg" className="hidden" />
</div>
<div className="w-full">
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Hub Name</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent mb-4"
placeholder="e.g. My Cool Club"
maxLength={32}
autoFocus
/>
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Invite Permissions</label>
<div className="flex items-center gap-3 bg-panel p-3 rounded">
<input
type="checkbox"
checked={allowAnyone}
onChange={(e) => setAllowAnyone(e.target.checked)}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Anyone can invite people to this hub</span>
</div>
<p className="text-[10px] text-muted mt-1">If unchecked, only you (the Admin) can send invites.</p>
</div>
</div>
<div className="bg-base flex justify-between items-center p-4 rounded-b-lg -m-6 mt-6 border-t border-panel">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
Back
</button>
<button onClick={handleCreate} disabled={!serverName.trim()} className="bg-accent hover:opacity-90 text-white px-6 py-2.5 rounded text-sm font-medium transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
Create
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import { ADMIN_PUBLIC_KEY } from '../p2p/index.js';
export default function DMList({ activeChannel, setActiveChannel, myKey, profile, unreadCounts, onOpenSettings, dms, servers, onlinePeers, typingUsers, activeCall, onReturnToCall, onOpenCreateGroup, onLeaveGroup, onDeleteGroup, isNetworkOnline }) {
const [now, setNow] = useState(Date.now());
const[contextMenu, setContextMenu] = useState(null);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
},[]);
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
},[contextMenu]);
const acceptedDMs = Object.entries(dms).filter(([_, data]) => data.status === 'accepted');
const pendingIncoming = Object.entries(dms).filter(([_, data]) => data.status === 'pending_incoming');
const groupChats = servers.filter(s => s.isGroupChat);
const renderDM = (pubKey, data) => {
const isActive = activeChannel === pubKey;
const unread = unreadCounts[pubKey] || 0;
const hasUnread = unread > 0 && !isActive;
const dmProfile = data.profile || { displayName: 'Unknown', username: 'unknown' };
const isOnline = onlinePeers.some(p => p.key === pubKey);
const isTyping = typingUsers[pubKey] &&
typingUsers[pubKey].channel === myKey &&
(now - typingUsers[pubKey].timestamp < 3000) &&
pubKey !== activeChannel;
return (
<div
key={pubKey}
onClick={() => setActiveChannel(pubKey)}
className={`px-2 py-1.5 rounded cursor-pointer flex items-center justify-between group ${
isActive ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'
}`}
>
<div className="flex items-center gap-3 overflow-hidden">
<div className="relative shrink-0 w-8 h-8">
<div className={`w-full h-full rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${dmProfile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{dmProfile.avatar ? (
<img src={dmProfile.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
dmProfile.displayName.substring(0, 2).toUpperCase()
)}
</div>
<div className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[3px] border-surface ${isOnline ? 'bg-green-500' : 'bg-gray-500'}`}></div>
</div>
<div className="flex flex-col overflow-hidden leading-tight justify-center">
<span className={`truncate ${hasUnread && !isTyping ? 'font-bold text-text' : ''}`}>{dmProfile.displayName}</span>
{isTyping && (
<div className="flex gap-1 items-center mt-0.5 ml-1">
<span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0s' }}></span>
<span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0.15s' }}></span>
<span className="w-1.5 h-1.5 rounded-full typing-dot" style={{ animationDelay: '0.3s' }}></span>
</div>
)}
</div>
</div>
{hasUnread && !isTyping && (
<div className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0">
{unread > 99 ? '99+' : unread}
</div>
)}
</div>
);
};
const renderGC = (gc) => {
const isActive = activeChannel === gc.topicHex;
const unread = unreadCounts[gc.topicHex] || 0;
const hasUnread = unread > 0 && !isActive;
return (
<div
key={gc.topicHex}
onClick={() => setActiveChannel(gc.topicHex)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.pageX, y: e.pageY, topicHex: gc.topicHex, isOwner: gc.owner === myKey });
}}
className={`px-2 py-1.5 rounded cursor-pointer flex items-center justify-between group ${
isActive ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'
}`}
>
<div className="flex items-center gap-3 overflow-hidden">
<div className="relative shrink-0 w-8 h-8">
<div className={`w-full h-full rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${gc.icon ? 'bg-transparent' : 'bg-accent'}`}>
{gc.icon ? (
<img src={gc.icon} alt="icon" className="w-full h-full object-cover" />
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm0 2a9.985 9.985 0 0 0-8 4 9.985 9.985 0 0 0 8 4 9.985 9.985 0 0 0 8-4 9.985 9.985 0 0 0-8-4z"/></svg>
)}
</div>
</div>
<div className="flex flex-col overflow-hidden leading-tight justify-center">
<span className={`truncate ${hasUnread ? 'font-bold text-text' : ''}`}>{gc.name}</span>
<span className="text-[10px] text-muted truncate">Group Whisper</span>
</div>
</div>
{hasUnread && (
<div className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0">
{unread > 99 ? '99+' : unread}
</div>
)}
</div>
);
};
return (
<div className="w-60 bg-surface flex flex-col shrink-0 relative border-r border-base">
{contextMenu && (
<div className="fixed inset-0 z-50" onClick={() => setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
<div
className="absolute bg-panel border border-surface shadow-xl rounded py-1.5 w-40 flex flex-col"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.isOwner ? (
<button
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
onClick={() => {
if (window.confirm("Are you sure you want to delete this group whisper? This will remove it for everyone and delete all message history.")) {
onDeleteGroup(contextMenu.topicHex);
setContextMenu(null);
}
}}
>
Delete Group
</button>
) : (
<button
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
onClick={() => {
onLeaveGroup(contextMenu.topicHex);
setContextMenu(null);
}}
>
Leave Group
</button>
)}
</div>
</div>
)}
{activeCall && (
<div
onClick={onReturnToCall}
className="bg-accent hover:opacity-90 text-white text-xs font-bold p-2 cursor-pointer flex items-center justify-center gap-2 transition-opacity shrink-0"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
Return to Call
</div>
)}
<div className="h-14 shadow-sm flex items-center px-4 border-b border-base shrink-0">
<button
onClick={() => setActiveChannel('friends')}
className="w-full bg-panel text-muted text-sm text-left px-3 py-1.5 rounded hover:bg-panel/80 transition-colors"
>
Find a conversation
</button>
</div>
<div className="flex-1 p-2 space-y-1 overflow-y-auto">
<div
onClick={() => setActiveChannel('friends')}
className={`px-2 py-2 rounded cursor-pointer flex items-center justify-between ${activeChannel === 'friends' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
<div className="flex items-center gap-3 font-medium">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm0 2a9.985 9.985 0 0 0-8 4 9.985 9.985 0 0 0 8 4 9.985 9.985 0 0 0 8-4 9.985 9.985 0 0 0-8-4z"/></svg>
Contacts
</div>
{pendingIncoming.length > 0 && (
<div className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full">
{pendingIncoming.length}
</div>
)}
</div>
<div className="px-2 py-1 mt-4 text-xs font-bold text-muted uppercase flex justify-between items-center">
<span>Whispers</span>
<button
onClick={onOpenCreateGroup}
className="text-accent hover:text-white bg-accent/10 hover:bg-accent/20 px-2 py-0.5 rounded transition-colors flex items-center gap-1"
title="Create Group Whisper"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
New
</button>
</div>
<div className="mt-2 space-y-0.5">
{groupChats.map(gc => renderGC(gc))}
{acceptedDMs.map(([pubKey, data]) => renderDM(pubKey, data))}
</div>
</div>
<div
className="h-16 bg-panel flex items-center px-3 gap-3 shrink-0 cursor-pointer hover:bg-surface transition-colors border-t border-base"
onClick={onOpenSettings}
>
<div className="relative shrink-0 w-10 h-10">
<div className={`w-full h-full rounded-md flex items-center justify-center text-white text-sm font-bold overflow-hidden ${profile.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{profile.avatar ? (
<img src={profile.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
profile.displayName.substring(0, 2).toUpperCase()
)}
</div>
<div className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[3px] border-panel ${isNetworkOnline ? 'bg-green-500' : 'bg-red-500'}`} title={isNetworkOnline ? "Online" : "Offline"}></div>
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-bold text-text leading-tight truncate flex items-center gap-1">
{profile.displayName}
{myKey === ADMIN_PUBLIC_KEY && <span title="Admin">👑</span>}
</span>
<span className="text-[10px] text-muted leading-tight truncate">@{profile.username}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { network } from '../p2p/index.js';
export default function FriendsView({ dms }) {
const [activeTab, setActiveTab] = useState('pending');
const [searchUsername, setSearchUsername] = useState('');
const[searchStatus, setSearchStatus] = useState(''); // 'searching', 'queued', 'found', 'error'
const pendingIncoming = Object.entries(dms).filter(([_, data]) => data.status === 'pending_incoming');
const pendingOutgoing = Object.entries(dms).filter(([_, data]) => data.status === 'pending_outgoing');
const handleAddFriend = async (e) => {
e.preventDefault();
const target = searchUsername.trim().toLowerCase();
if (!target) return;
if (target === network.username) {
setSearchStatus('error');
return;
}
setSearchStatus('searching');
const result = await network.searchUser(target);
if (result) {
await network.sendDMRequest(result.pubKey, result.profile);
setSearchStatus('found');
setSearchUsername('');
} else {
await network.queueFriendRequest(target);
setSearchStatus('queued');
setSearchUsername('');
}
};
return (
<div className="flex-1 flex flex-col bg-base min-w-0">
<div className="h-14 shadow-sm flex items-center px-4 border-b border-surface gap-6 shrink-0 bg-panel z-10">
<div className="flex items-center gap-2 text-text font-bold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-muted"><path d="M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm0 2a9.985 9.985 0 0 0-8 4 9.985 9.985 0 0 0 8 4 9.985 9.985 0 0 0 8-4 9.985 9.985 0 0 0-8-4z"/></svg>
Contacts
</div>
<div className="w-[1px] h-6 bg-surface"></div>
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'pending' ? 'bg-surface text-text' : 'text-muted hover:bg-surface/50 hover:text-text'}`}
>
Pending {pendingIncoming.length > 0 && <span className="bg-red-500 text-white text-xs px-1.5 rounded-full ml-1">{pendingIncoming.length}</span>}
</button>
<button
onClick={() => setActiveTab('add')}
className={`px-3 py-1 rounded font-medium text-sm transition-colors ${activeTab === 'add' ? 'bg-accent text-white' : 'bg-accent/20 text-accent hover:bg-accent/30'}`}
>
Add Contact
</button>
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto">
{activeTab === 'pending' && (
<div>
<h2 className="text-xs font-bold text-muted uppercase mb-4">Pending Requests {pendingIncoming.length + pendingOutgoing.length}</h2>
<div className="space-y-2">
{pendingIncoming.map(([pubKey, data]) => (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-md bg-indigo-500 flex items-center justify-center text-white font-bold overflow-hidden">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username} Incoming Contact Request</span>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => network.acceptDMRequest(pubKey)} className="w-9 h-9 rounded-full bg-surface flex items-center justify-center text-green-500 hover:bg-green-500 hover:text-white transition-colors border border-panel" title="Accept">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
</div>
</div>
))}
{pendingOutgoing.map(([pubKey, data]) => (
<div key={pubKey} className="flex items-center justify-between p-3 hover:bg-panel rounded-lg border-t border-surface group transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-md bg-surface flex items-center justify-center text-muted font-bold overflow-hidden border border-panel">
{data.profile?.avatar ? <img src={data.profile.avatar} className="w-full h-full object-cover"/> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-text font-bold">{data.profile?.displayName}</span>
<span className="text-xs text-muted">@{data.profile?.username} Outgoing Contact Request</span>
</div>
</div>
</div>
))}
{pendingIncoming.length === 0 && pendingOutgoing.length === 0 && (
<div className="text-center text-muted mt-10">No pending requests.</div>
)}
</div>
</div>
)}
{activeTab === 'add' && (
<div className="max-w-2xl">
<h2 className="text-text font-bold mb-2">ADD CONTACT</h2>
<p className="text-sm text-muted mb-4">You can add a contact with their username. It's case sensitive!</p>
<form onSubmit={handleAddFriend} className="relative flex items-center">
<input
type="text"
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
placeholder="You can add a contact with their username."
className="w-full bg-panel text-text rounded-lg p-4 pr-40 outline-none focus:ring-1 focus:ring-accent border border-surface"
/>
<button
type="submit"
disabled={!searchUsername.trim() || searchStatus === 'searching'}
className="absolute right-2 bg-accent hover:opacity-90 text-white px-4 py-2 rounded text-sm font-medium transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
Send Request
</button>
</form>
{searchStatus === 'searching' && <p className="text-accent text-sm mt-2">Searching network...</p>}
{searchStatus === 'found' && <p className="text-green-500 text-sm mt-2">Success! Your contact request was sent.</p>}
{searchStatus === 'queued' && <p className="text-yellow-500 text-sm mt-2">User is currently offline. We queued your request and will send it automatically when they come online!</p>}
{searchStatus === 'error' && <p className="text-red-500 text-sm mt-2">You cannot send a contact request to yourself.</p>}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,635 @@
import React, { useEffect, useRef, useState } from 'react';
import { network } from '../p2p/index.js';
import ScreenShareModal from './ScreenShareModal.jsx';
const VideoPlayer = ({ stream, muted, isAudioOnly }) => {
const ref = useRef();
useEffect(() => {
if (ref.current && stream) {
ref.current.srcObject = stream;
const outputId = localStorage.getItem('pear_audio_output');
if (outputId && outputId !== 'default' && ref.current.setSinkId) {
ref.current.setSinkId(outputId).catch(console.error);
}
}
}, [stream]);
if (isAudioOnly) {
return <audio ref={ref} autoPlay muted={muted} className="hidden" />;
}
return <video ref={ref} autoPlay playsInline muted={muted} className="w-full h-full object-cover" />;
};
export default function GroupCallView({ channel, serverTopicHex, vcChannelId, myKey, myProfile, knownUsers, onClose, onToggleChat, onLocalStateChange, className, initialVideoOn }) {
const [isMuted, setIsMuted] = useState(false);
const [isVideoOn, setIsVideoOn] = useState(initialVideoOn || false);
const [localVoiceActive, setLocalVoiceActive] = useState(false);
const [showScreenShareModal, setShowScreenShareModal] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [expandedStreamId, setExpandedStreamId] = useState(null);
// { [peerKey]: { streams: MediaStream[], voiceActive: boolean } }
const [peers, setPeers] = useState({});
const pcs = useRef({});
const pendingCandidates = useRef({});
const makingOffer = useRef({});
const ignoreOffer = useRef({});
const localStreamRef = useRef(null);
const localScreenStreamRef = useRef(null);
const localCameraStreamRef = useRef(null);
const audioCtxRef = useRef(null);
const animationFrameRef = useRef(null);
// Broadcast VC state to the server swarm if this is a server VC
useEffect(() => {
if (serverTopicHex && vcChannelId) {
const broadcastState = () => {
network.sendEphemeral({
type: 'vc-state',
serverTopicHex,
channel: vcChannelId,
muted: isMuted,
screenshare: isScreenSharing
});
if (onLocalStateChange) onLocalStateChange(isMuted, isScreenSharing);
};
broadcastState(); // Initial broadcast
const interval = setInterval(broadcastState, 3000);
return () => {
clearInterval(interval);
// Leave is handled by onClose in MainApp, but we also send it here on unmount just in case
network.sendEphemeral({
type: 'vc-leave',
serverTopicHex,
channel: vcChannelId
});
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},[serverTopicHex, vcChannelId, isMuted, isScreenSharing]);
const sendSignal = (targetKey, signal) => {
network.sendWebRTCSignal(targetKey, { type: 'webrtc-group-signal', channel, target: targetKey, signal });
};
const createPC = (peerKey) => {
const pc = new RTCPeerConnection({ iceServers:[{ urls: 'stun:stun.l.google.com:19302' }] });
makingOffer.current[peerKey] = false;
ignoreOffer.current[peerKey] = false;
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localStreamRef.current));
}
if (localScreenStreamRef.current) {
localScreenStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localScreenStreamRef.current));
}
if (localCameraStreamRef.current) {
localCameraStreamRef.current.getTracks().forEach(t => pc.addTrack(t, localCameraStreamRef.current));
}
pc.onicecandidate = (e) => {
if (e.candidate) sendSignal(peerKey, { type: 'ice', candidate: e.candidate });
};
pc.ontrack = (e) => {
setPeers(prev => {
const existing = prev[peerKey] || { streams:[], voiceActive: false };
const streamExists = existing.streams.find(s => s.id === e.streams[0].id);
if (!streamExists) {
return { ...prev, [peerKey]: { ...existing, streams:[...existing.streams, e.streams[0]] } };
}
return prev;
});
};
// Perfect Negotiation: Anyone can create an offer when needed
pc.onnegotiationneeded = async () => {
try {
makingOffer.current[peerKey] = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendSignal(peerKey, { type: 'offer', sdp: pc.localDescription });
} catch (err) {
console.error("Failed to create offer:", err);
} finally {
makingOffer.current[peerKey] = false;
}
};
pcs.current[peerKey] = pc;
return pc;
};
useEffect(() => {
const setupMedia = async () => {
try {
const audioInputId = localStorage.getItem('pear_audio_input');
const aStream = await navigator.mediaDevices.getUserMedia({
audio: audioInputId && audioInputId !== 'default' ? { deviceId: { exact: audioInputId } } : true
});
localStreamRef.current = aStream;
if (initialVideoOn) {
try {
const videoInputId = localStorage.getItem('pear_video_input');
const vStream = await navigator.mediaDevices.getUserMedia({
video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
});
localCameraStreamRef.current = vStream;
} catch (err) {
console.error("Failed to get video", err);
setIsVideoOn(false);
}
}
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
audioCtxRef.current = audioCtx;
const analyser = audioCtx.createAnalyser();
const source = audioCtx.createMediaStreamSource(aStream);
source.connect(analyser);
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let lastSpeakingState = false;
const checkAudio = () => {
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
const average = sum / bufferLength;
const isSpeaking = average > 15;
if (isSpeaking !== lastSpeakingState) {
network.sendEphemeral({ type: 'webrtc-group-voice', channel, state: isSpeaking ? 'speaking' : 'silent' });
setLocalVoiceActive(isSpeaking);
lastSpeakingState = isSpeaking;
}
animationFrameRef.current = requestAnimationFrame(checkAudio);
};
checkAudio();
// Broadcast join to the GC or VC mesh
network.sendEphemeral({ type: 'webrtc-group-join', channel });
} catch (err) {
console.error("Failed to access microphone:", err);
alert("Could not access microphone. Please check your permissions.");
onClose();
}
};
setupMedia();
// Cleanup dead streams periodically
const cleanupInterval = setInterval(() => {
setPeers(prev => {
let changed = false;
const next = {};
for (const [key, peer] of Object.entries(prev)) {
const activeStreams = peer.streams.filter(s => s.active && s.getTracks().length > 0);
if (activeStreams.length !== peer.streams.length) changed = true;
next[key] = { ...peer, streams: activeStreams };
}
return changed ? next : prev;
});
}, 2000);
return () => {
clearInterval(cleanupInterval);
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
if (audioCtxRef.current) audioCtxRef.current.close().catch(() => {});
if (localStreamRef.current) localStreamRef.current.getTracks().forEach(t => t.stop());
if (localScreenStreamRef.current) localScreenStreamRef.current.getTracks().forEach(t => t.stop());
if (localCameraStreamRef.current) localCameraStreamRef.current.getTracks().forEach(t => t.stop());
Object.values(pcs.current).forEach(pc => pc.close());
pcs.current = {};
network.sendEphemeral({ type: 'webrtc-group-leave', channel });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);
useEffect(() => {
const handleSignal = async (peerKey, payload) => {
if (payload.channel !== channel) return;
if (payload.type === 'webrtc-group-join' && peerKey !== myKey) {
if (!pcs.current[peerKey]) createPC(peerKey);
// Send a ping back so the other peer knows we are here if they joined earlier
network.sendEphemeral({ type: 'webrtc-group-hello', channel, target: peerKey });
}
else if (payload.type === 'webrtc-group-hello' && payload.target === myKey) {
if (!pcs.current[peerKey]) createPC(peerKey);
}
else if (payload.type === 'webrtc-group-leave') {
if (pcs.current[peerKey]) {
pcs.current[peerKey].close();
delete pcs.current[peerKey];
}
setPeers(prev => {
const next = { ...prev };
delete next[peerKey];
return next;
});
}
else if (payload.type === 'webrtc-group-voice') {
setPeers(prev => {
if (!prev[peerKey]) return prev;
return { ...prev, [peerKey]: { ...prev[peerKey], voiceActive: payload.state === 'speaking' } };
});
}
else if (payload.type === 'webrtc-group-signal' && payload.target === myKey) {
const { signal } = payload;
let pc = pcs.current[peerKey];
if (!pc) pc = createPC(peerKey);
if (signal.type === 'offer' || signal.type === 'answer') {
// Perfect Negotiation Collision Resolution
const isPolite = myKey < peerKey;
const offerCollision = signal.type === 'offer' && (makingOffer.current[peerKey] || pc.signalingState !== 'stable');
ignoreOffer.current[peerKey] = !isPolite && offerCollision;
if (ignoreOffer.current[peerKey]) {
return;
}
try {
await pc.setRemoteDescription(signal.sdp);
if (signal.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendSignal(peerKey, { type: 'answer', sdp: pc.localDescription });
}
if (pendingCandidates.current[peerKey]) {
for (const c of pendingCandidates.current[peerKey]) {
await pc.addIceCandidate(c).catch(console.error);
}
pendingCandidates.current[peerKey] =[];
}
} catch (err) {
console.error("Error setting remote description:", err);
}
}
else if (signal.type === 'ice') {
try {
if (pc && pc.remoteDescription) {
await pc.addIceCandidate(signal.candidate);
} else {
if (!pendingCandidates.current[peerKey]) pendingCandidates.current[peerKey] =[];
pendingCandidates.current[peerKey].push(signal.candidate);
}
} catch (err) {
if (!ignoreOffer.current[peerKey]) {
console.error("Error adding ICE candidate:", err);
}
}
}
}
};
network.addWebRTCListener(handleSignal);
return () => network.removeWebRTCListener(handleSignal);
},[channel, myKey]);
const toggleMute = () => {
if (localStreamRef.current) {
const audioTrack = localStreamRef.current.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
setIsMuted(!audioTrack.enabled);
}
}
};
const toggleVideo = async () => {
if (isVideoOn) {
if (localCameraStreamRef.current) {
const track = localCameraStreamRef.current.getVideoTracks()[0];
Object.values(pcs.current).forEach(pc => {
const sender = pc.getSenders().find(s => s.track === track);
if (sender) pc.removeTrack(sender);
});
track.stop();
localCameraStreamRef.current = null;
}
setIsVideoOn(false);
} else {
try {
const videoInputId = localStorage.getItem('pear_video_input');
const stream = await navigator.mediaDevices.getUserMedia({
video: videoInputId && videoInputId !== 'default' ? { deviceId: { exact: videoInputId } } : true
});
localCameraStreamRef.current = stream;
const track = stream.getVideoTracks()[0];
Object.values(pcs.current).forEach(pc => {
pc.addTrack(track, stream);
});
setIsVideoOn(true);
} catch (err) {
console.error("Failed to start video", err);
}
}
};
const startScreenShare = async (sourceId, res, fps) => {
setShowScreenShareModal(false);
let stream = null;
try {
if (sourceId === 'native') {
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
} else {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxWidth: res.width,
maxHeight: res.height,
maxFrameRate: fps
}
}
});
} catch (initialErr) {
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: sourceId } }
});
}
}
const videoTrack = stream.getVideoTracks()[0];
videoTrack.contentHint = 'motion';
videoTrack.onended = () => stopScreenShare();
localScreenStreamRef.current = stream;
setIsScreenSharing(true);
// Add track to all existing peer connections
Object.values(pcs.current).forEach(pc => {
const sender = pc.addTrack(videoTrack, stream);
try {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) params.encodings = [{}];
params.encodings[0].maxFramerate = fps;
let maxBitrate = 8000000;
if (res.height <= 720) maxBitrate = 4000000;
if (res.height <= 480) maxBitrate = 1500000;
if (res.height <= 360) maxBitrate = 800000;
params.encodings[0].maxBitrate = maxBitrate;
if ('degradationPreference' in params) params.degradationPreference = 'maintain-framerate';
sender.setParameters(params);
} catch (e) {}
});
} catch (err) {
console.error("Screen share failed", err);
if (stream) stream.getTracks().forEach(t => { t.enabled = false; t.stop(); });
setIsScreenSharing(false);
}
};
const stopScreenShare = async () => {
if (localScreenStreamRef.current) {
const trackToRemove = localScreenStreamRef.current.getVideoTracks()[0];
Object.values(pcs.current).forEach(pc => {
const sender = pc.getSenders().find(s => s.track === trackToRemove);
if (sender) pc.removeTrack(sender);
});
localScreenStreamRef.current.getTracks().forEach(t => { t.enabled = false; t.stop(); });
localScreenStreamRef.current = null;
setIsScreenSharing(false);
if (expandedStreamId === 'local-screen') {
setExpandedStreamId(null);
}
}
};
// Flatten all streams for the grid
const gridItems = [];
// Local User
if (isVideoOn && localCameraStreamRef.current) {
gridItems.push({
id: 'local-user',
isLocal: true,
name: `${myProfile.displayName} (You)`,
stream: localCameraStreamRef.current,
isAudioOnly: false,
voiceActive: localVoiceActive && !isMuted
});
} else {
gridItems.push({
id: 'local-user',
isLocal: true,
name: `${myProfile.displayName} (You)`,
avatar: myProfile.avatar,
voiceActive: localVoiceActive && !isMuted,
stream: null,
isAudioOnly: true
});
}
// Local Screen Share
if (isScreenSharing && localScreenStreamRef.current) {
gridItems.push({
id: 'local-screen',
isLocal: true,
name: 'Your Screen',
stream: localScreenStreamRef.current,
isAudioOnly: false
});
}
// Remote Peers
Object.entries(peers).forEach(([peerKey, peer]) => {
const profile = knownUsers.find(u => u.key === peerKey) || { displayName: 'Unknown' };
let audioStream = null;
peer.streams.forEach((stream, i) => {
if (stream.getVideoTracks().length > 0) {
gridItems.push({
id: `${peerKey}-video-${i}`,
isLocal: false,
name: `${profile.displayName}'s Screen`,
stream: stream,
isAudioOnly: false
});
} else if (stream.getAudioTracks().length > 0) {
audioStream = stream;
}
});
// Always add their voice box (avatar)
gridItems.push({
id: `${peerKey}-voice`,
isLocal: false,
name: profile.displayName,
avatar: profile.avatar,
voiceActive: peer.voiceActive,
stream: audioStream,
isAudioOnly: true
});
});
return (
<div className={`bg-[#1e1f22] flex flex-col relative ${className}`}>
{/* Header */}
<div className="h-12 shadow-sm flex items-center px-4 border-b border-gray-900/20 gap-2 shrink-0">
<span className="font-bold text-white">
{vcChannelId ? `Voice Channel: ${vcChannelId}` : 'Group Call'}
</span>
<span className="ml-2 text-xs font-bold uppercase tracking-widest text-green-500 flex items-center gap-1">
Connected {Object.keys(peers).length + 1} in call
</span>
</div>
{/* Main Call Area (Auto-sizing Grid) */}
<div className="flex-1 p-4 overflow-y-auto custom-scrollbar">
<div className="grid gap-4 auto-rows-fr" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
{gridItems.map(item => (
<div
key={item.id}
className={`group bg-[#2b2d31] rounded-xl flex flex-col items-center justify-center transition-all duration-300 shadow-lg border border-gray-800 relative overflow-hidden aspect-video ${item.voiceActive ? 'ring-2 ring-green-500' : 'ring-2 ring-transparent'}`}
>
{item.stream && !item.isAudioOnly ? (
<>
<VideoPlayer stream={item.stream} muted={item.isLocal} isAudioOnly={false} />
<button
onClick={(e) => { e.stopPropagation(); setExpandedStreamId(item.id); }}
className="absolute top-2 right-2 bg-black/60 hover:bg-black/80 text-white p-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity z-10"
title="Full Screen"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>
</button>
</>
) : (
<>
{item.stream && item.isAudioOnly && <VideoPlayer stream={item.stream} muted={item.isLocal} isAudioOnly={true} />}
<div className={`rounded-full flex items-center justify-center text-white font-bold overflow-hidden w-24 h-24 text-3xl ${item.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{item.avatar ? (
<img src={item.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
item.name.substring(0, 2).toUpperCase()
)}
</div>
</>
)}
<span className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded backdrop-blur-sm font-medium">
{item.name}
</span>
</div>
))}
</div>
</div>
{/* Bottom Controls */}
<div className="h-20 bg-[#2b2d31] flex items-center justify-center gap-4 shrink-0 rounded-t-2xl mx-4 border-t border-x border-gray-800">
<button
onClick={toggleMute}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-[#383a40] text-gray-300 hover:bg-gray-600'}`}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>
)}
</button>
<button
onClick={toggleVideo}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${!isVideoOn ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-[#383a40] text-gray-300 hover:bg-gray-600'}`}
title={isVideoOn ? "Turn Off Camera" : "Turn On Camera"}
>
{isVideoOn ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
)}
</button>
<button
onClick={onToggleChat}
className="px-6 h-10 rounded bg-[#383a40] hover:bg-gray-600 text-white font-medium flex items-center gap-2 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
Chat
</button>
{isScreenSharing ? (
<button
onClick={stopScreenShare}
className="px-6 h-10 rounded bg-red-500 hover:bg-red-600 text-white font-medium flex items-center gap-2 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line><line x1="1" y1="1" x2="23" y2="23"></line></svg>
Stop Sharing
</button>
) : (
<button
onClick={() => setShowScreenShareModal(true)}
className="px-6 h-10 rounded bg-[#383a40] hover:bg-gray-600 text-white font-medium flex items-center gap-2 transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Share Screen
</button>
)}
<button
onClick={onClose}
className="px-6 h-10 rounded bg-red-500 hover:bg-red-600 text-white font-medium flex items-center gap-2 transition-colors"
>
Disconnect
</button>
</div>
{showScreenShareModal && (
<ScreenShareModal
onClose={() => setShowScreenShareModal(false)}
onStart={startScreenShare}
/>
)}
{/* Fullscreen Expanded View */}
{expandedStreamId && (() => {
const expandedItem = gridItems.find(i => i.id === expandedStreamId);
if (!expandedItem) {
setExpandedStreamId(null);
return null;
}
return (
<div className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center backdrop-blur-sm" onClick={() => setExpandedStreamId(null)}>
<div className="relative w-full h-full flex items-center justify-center p-8">
<VideoPlayer stream={expandedItem.stream} muted={expandedItem.isLocal} isAudioOnly={false} />
<button
className="absolute top-6 right-6 text-white hover:text-gray-300 bg-black/50 hover:bg-black/80 rounded-full p-2 transition-colors"
onClick={(e) => { e.stopPropagation(); setExpandedStreamId(null); }}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<span className="absolute bottom-8 left-8 bg-black/60 text-white text-lg px-4 py-2 rounded backdrop-blur-sm font-medium">
{expandedItem.name}
</span>
</div>
</div>
);
})()}
</div>
);
}

View File

@ -0,0 +1,92 @@
import React, { useEffect } from 'react';
export default function IncomingCallModal({ incomingCall, onAccept, onDecline }) {
useEffect(() => {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let interval;
const beep = (freq, startTime, duration) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, startTime);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.1, startTime + 0.05);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(startTime);
osc.stop(startTime + duration);
};
const playRing = () => {
if (audioCtx.state === 'suspended') audioCtx.resume();
const now = audioCtx.currentTime;
beep(440, now, 0.4);
beep(523.25, now + 0.2, 0.4);
};
playRing();
interval = setInterval(playRing, 2000);
return () => {
clearInterval(interval);
audioCtx.close().catch(() => {});
};
},[]);
const isGroup = incomingCall.isGroup;
const isVideo = incomingCall.callType === 'video';
const title = isGroup ? incomingCall.gcName : incomingCall.profile.displayName;
const subtitle = isGroup ? `Group Whisper started by ${incomingCall.callerName}` : (isVideo ? 'INCOMING VIDEO CALL' : 'INCOMING VOICE CALL');
const avatar = isGroup ? null : incomingCall.profile.avatar;
const fallback = isGroup ? '👥' : (incomingCall.profile.displayName?.substring(0, 2).toUpperCase() || '?');
return (
<div className="absolute inset-0 z-50 bg-black/60 flex items-center justify-center backdrop-blur-sm">
<div className="bg-surface p-8 rounded-2xl shadow-2xl flex flex-col items-center gap-6 border border-panel">
<div className="flex flex-col items-center gap-2">
<div className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold overflow-hidden shadow-lg ${avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{avatar ? (
<img src={avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
fallback
)}
</div>
<h2 className="text-2xl font-bold text-text mt-2">{title}</h2>
<div className="text-muted text-sm uppercase tracking-widest font-bold flex items-center gap-1">
{subtitle}
<span className="flex gap-0.5 items-center mt-1">
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0s' }}></span>
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0.15s' }}></span>
<span className="w-1 h-1 bg-muted rounded-full typing-dot" style={{ animationDelay: '0.3s' }}></span>
</span>
</div>
</div>
<div className="flex gap-6 mt-4 w-full">
<button
onClick={onDecline}
className="flex-1 bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-6 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
Decline
</button>
<button
onClick={onAccept}
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
Answer
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { network } from '../p2p/index.js';
export default function InviteModal({ onClose, serverTopicHex, dms, serverMembers, isGroupChat }) {
const[sentInvites, setSentInvites] = useState(new Set());
const[searchQuery, setSearchQuery] = useState('');
const membersSet = new Set(serverMembers[serverTopicHex] ||[]);
const friends = Object.entries(dms)
.filter(([_, data]) => data.status === 'accepted')
.filter(([_, data]) =>
data.profile?.displayName?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleInvite = (friendKey) => {
if (sentInvites.has(friendKey) || membersSet.has(friendKey)) return;
if (isGroupChat) {
network.sendGroupChatAdd(friendKey, serverTopicHex);
} else {
network.sendServerInvite(friendKey, serverTopicHex);
}
setSentInvites(prev => new Set(prev).add(friendKey));
};
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-md flex flex-col border border-panel" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-panel flex flex-col gap-3">
<div className="flex justify-between items-center">
<h2 className="text-lg font-bold text-text">{isGroupChat ? 'Add contacts to Group Whisper' : 'Invite contacts'}</h2>
<button onClick={onClose} className="text-muted hover:text-text transition-colors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div className="relative">
<input
type="text"
placeholder="Search for contacts"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-panel text-text rounded p-2 pl-8 outline-none focus:ring-1 focus:ring-accent text-sm"
autoFocus
/>
<svg className="absolute left-2.5 top-2.5 text-muted" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
</div>
<div className="p-4 pt-2">
<div className="max-h-64 overflow-y-auto space-y-2 pr-2 hide-scrollbar">
{friends.length === 0 ? (
<div className="text-center text-muted py-4 text-sm">
{searchQuery ? "No contacts found matching that name." : "You don't have any contacts to invite yet. Add some from the Whispers tab!"}
</div>
) : (
friends.map(([friendKey, data]) => {
const isSent = sentInvites.has(friendKey);
const isMember = membersSet.has(friendKey);
return (
<div key={friendKey} className="flex items-center justify-between group hover:bg-panel p-2 rounded transition-colors">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${data.profile?.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{data.profile?.avatar ? <img src={data.profile?.avatar} className="w-full h-full object-cover" /> : data.profile?.displayName?.substring(0, 2).toUpperCase()}
</div>
<span className="text-text font-medium">{data.profile?.displayName}</span>
</div>
<button
onClick={() => handleInvite(friendKey)}
disabled={isSent || isMember}
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${
isMember ? 'bg-transparent border border-surface text-muted cursor-not-allowed' :
isSent ? 'bg-transparent border border-green-500 text-green-500 cursor-not-allowed' : 'bg-accent hover:opacity-90 text-white'
}`}
>
{isMember ? 'Added' : isSent ? 'Sent' : (isGroupChat ? 'Add' : 'Invite')}
</button>
</div>
);
})
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,706 @@
import React, { useEffect, useState, useRef } from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
import Sidebar from './Sidebar.jsx';
import ChannelList from './ChannelList.jsx';
import DMList from './DMList.jsx';
import ChatArea from './ChatArea.jsx';
import FriendsView from './FriendsView.jsx';
import OnlineUsers from './OnlineUsers.jsx';
import ProfileSettingsModal from './ProfileSettingsModal.jsx';
import CreateServerModal from './CreateServerModal.jsx';
import CreateGroupModal from './CreateGroupModal.jsx';
import InviteModal from './InviteModal.jsx';
import ServerSettingsModal from './ServerSettingsModal.jsx';
import CallView from './CallView.jsx';
import GroupCallView from './GroupCallView.jsx';
import IncomingCallModal from './IncomingCallModal.jsx';
export default function MainApp({ profile, setProfile, onLogout, updateState, simulatedProgress, triggerRestart, onSystemUpdate }) {
const[myKey, setMyKey] = useState('');
const[onlinePeers, setOnlinePeers] = useState([]);
const[knownUsers, setKnownUsers] = useState([]);
const[messages, setMessages] = useState([]);
const[servers, setServers] = useState([]);
const[serverMembers, setServerMembers] = useState({});
const[isSyncing, setIsSyncing] = useState(false);
const[transfers, setTransfers] = useState({});
const[activeView, setActiveView] = useState('dms');
const[activeChannel, setActiveChannel] = useState('general-chat');
const[activeDm, setActiveDm] = useState('friends');
const[dms, setDms] = useState({});
const[typingUsers, setTypingUsers] = useState({});
const[readReceipts, setReadReceipts] = useState(() => JSON.parse(localStorage.getItem('pear_read_receipts') || '{}'));
const[deliveredReceipts, setDeliveredReceipts] = useState(() => JSON.parse(localStorage.getItem('pear_delivered_receipts') || '{}'));
const[lastRead, setLastRead] = useState(() => JSON.parse(localStorage.getItem('pear_last_read') || '{}'));
const[isSettingsOpen, setIsSettingsOpen] = useState(false);
const[isCreateServerOpen, setIsCreateServerOpen] = useState(false);
const[isCreateGroupOpen, setIsCreateGroupOpen] = useState(false);
const[inviteModalServer, setInviteModalServer] = useState(null);
const[settingsModalServer, setSettingsModalServer] = useState(null);
// Call States
const[activeCall, setActiveCall] = useState(null);
const[activeGroupCall, setActiveGroupCall] = useState(null);
const[activeVc, setActiveVc] = useState(null);
const[incomingCall, setIncomingCall] = useState(null);
const[showChatInCall, setShowChatInCall] = useState(false);
const callTimeoutRef = useRef(null);
const[vcStates, setVcStates] = useState({});
const[showMembersDrawer, setShowMembersDrawer] = useState(false);
const [isNetworkOnline, setIsNetworkOnline] = useState(typeof navigator !== 'undefined' ? navigator.onLine : true);
const initialized = useRef(false);
useEffect(() => {
const handleOnline = () => {
setIsNetworkOnline(true);
setOnlinePeers(network.getPeerList());
network.reconnect();
};
const handleOffline = () => {
setIsNetworkOnline(false);
setOnlinePeers([]);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
localStorage.setItem('pear_last_read', JSON.stringify(lastRead));
},[lastRead]);
useEffect(() => {
localStorage.setItem('pear_read_receipts', JSON.stringify(readReceipts));
},[readReceipts]);
useEffect(() => {
localStorage.setItem('pear_delivered_receipts', JSON.stringify(deliveredReceipts));
},[deliveredReceipts]);
useEffect(() => {
if (!initialized.current && typeof window !== 'undefined') {
initialized.current = true;
network.onInit = (key) => setMyKey(key);
network.onPeerUpdate = (peers) => {
if (typeof navigator !== 'undefined' && navigator.onLine) {
setOnlinePeers([...peers]);
}
};
network.onKnownProfilesUpdate = (users) => setKnownUsers(users);
network.onMessage = (msgs) => setMessages([...msgs]);
network.onDMsUpdate = (updatedDms) => setDms(updatedDms);
network.onTransfersUpdate = (t) => setTransfers({...t});
network.onServersUpdate = (srvs) => {
setServers([...srvs]);
setActiveView(prev => {
if (prev !== 'dms' && !srvs.some(s => s.topicHex === prev)) return 'dms';
return prev;
});
};
network.onServerMembersUpdate = (members) => setServerMembers(members);
network.onSync = (status) => setIsSyncing(status);
network.onEphemeral = (peerKey, payload) => {
if (payload.type === 'system_update') {
try {
const b4a = window.require('b4a');
const sodium = window.require('sodium-native');
const sigBuf = b4a.from(payload.signature, 'hex');
const msgBuf = b4a.from(payload.version + payload.timestamp);
const pubBuf = b4a.from(ADMIN_PUBLIC_KEY, 'hex');
const isValid = sodium.crypto_sign_verify_detached(sigBuf, msgBuf, pubBuf);
if (isValid && payload.version !== window.APP_VERSION) {
if (onSystemUpdate) onSystemUpdate(payload.version, payload);
} else if (!isValid) {
console.warn('[P2P] Received invalid update broadcast signature.');
}
} catch (e) {
console.error("Failed to verify update broadcast", e);
}
} else if (payload.type === 'typing') {
setTypingUsers(prev => ({
...prev,[peerKey]: { channel: payload.channel, displayName: payload.displayName, timestamp: Date.now() }
}));
} else if (payload.type === 'read') {
const markChannel = payload.channel === network.myKey ? peerKey : payload.channel;
setReadReceipts(prev => ({ ...prev, [markChannel]: payload.messageId }));
} else if (payload.type === 'delivered') {
const markChannel = payload.channel === network.myKey ? peerKey : payload.channel;
setDeliveredReceipts(prev => ({ ...prev,[markChannel]: payload.messageId }));
} else if (payload.type === 'vc-state') {
setVcStates(prev => {
const serverVCS = prev[payload.serverTopicHex] || {};
const channelVCS = serverVCS[payload.channel] || {};
return {
...prev,
[payload.serverTopicHex]: {
...serverVCS,
[payload.channel]: {
...channelVCS,
[peerKey]: {
muted: payload.muted,
screenshare: payload.screenshare,
timestamp: Date.now()
}
}
}
};
});
} else if (payload.type === 'vc-leave') {
setVcStates(prev => {
const serverVCS = prev[payload.serverTopicHex];
if (!serverVCS) return prev;
const channelVCS = serverVCS[payload.channel];
if (!channelVCS) return prev;
const newChannelVCS = { ...channelVCS };
delete newChannelVCS[peerKey];
return {
...prev,
[payload.serverTopicHex]: {
...serverVCS,
[payload.channel]: newChannelVCS
}
};
});
}
};
network.initialize(profile.seedHex, profile.displayName, profile.username, profile.avatar)
.catch(err => {
alert("P2P Initialization Error:\n" + err.message + "\n\nPress F12 to open DevTools for more info.");
console.error(err);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setVcStates(prev => {
let changed = false;
const next = { ...prev };
for (const serverId in next) {
for (const channelId in next[serverId]) {
for (const peerKey in next[serverId][channelId]) {
if (now - next[serverId][channelId][peerKey].timestamp > 10000) {
delete next[serverId][channelId][peerKey];
changed = true;
}
}
}
}
return changed ? next : prev;
});
}, 5000);
return () => clearInterval(interval);
},[]);
useEffect(() => {
if (!myKey) return;
onlinePeers.forEach(peer => {
const msgsFromPeer = messages.filter(m => m.sender === peer.key && m.recipient === myKey);
if (msgsFromPeer.length > 0) {
const lastMsg = msgsFromPeer[msgsFromPeer.length - 1];
network.sendDeliveredReceipt(peer.key, lastMsg.id);
}
});
},[messages.length, onlinePeers.length, myKey]);
useEffect(() => {
network.activeCalls = (activeCall ? 1 : 0) + (activeGroupCall ? 1 : 0) + (activeVc ? 1 : 0);
},[activeCall, activeGroupCall, activeVc]);
useEffect(() => {
if (activeView === 'dms' && activeDm !== 'friends') {
const isUserDm = !!dms[activeDm];
const isGc = servers.some(s => s.topicHex === activeDm && s.isGroupChat);
if (!isUserDm && !isGc) {
setActiveDm('friends');
}
}
},[servers, dms, activeView, activeDm]);
useEffect(() => {
const handleWebRTC = (peerKey, payload) => {
if (payload.type === 'webrtc-init') {
if (!activeCall && !activeGroupCall && !activeVc) {
const callerProfile = knownUsers.find(u => u.key === peerKey) || dms[peerKey]?.profile || { displayName: 'Unknown' };
setIncomingCall({ isGroup: false, targetKey: peerKey, profile: callerProfile, callType: payload.callType || 'voice' });
} else {
network.sendWebRTCSignal(peerKey, { type: 'webrtc-busy' });
}
} else if (payload.type === 'webrtc-cancel') {
setIncomingCall(current => (current && !current.isGroup && current.targetKey === peerKey) ? null : current);
} else if (payload.type === 'webrtc-accept') {
setActiveCall(current => {
if (current?.targetKey === peerKey) {
if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
return { ...current, status: 'connecting' };
}
return current;
});
} else if (payload.type === 'webrtc-decline' || payload.type === 'webrtc-busy') {
setActiveCall(current => {
if (current?.targetKey === peerKey) {
if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
alert(payload.type === 'webrtc-busy' ? 'User is busy' : 'Call declined');
return null;
}
return current;
});
} else if (payload.type === 'webrtc-end') {
setActiveCall(current => current?.targetKey === peerKey ? null : current);
}
else if (payload.type === 'webrtc-group-ring') {
const gc = servers.find(s => s.topicHex === payload.channel && s.isGroupChat);
if (gc && activeGroupCall?.channel !== payload.channel && !activeVc) {
setIncomingCall({ isGroup: true, channel: payload.channel, callerName: payload.callerName, gcName: gc.name, callType: payload.callType || 'voice' });
}
}
};
network.addWebRTCListener(handleWebRTC);
return () => network.removeWebRTCListener(handleWebRTC);
},[activeCall, activeGroupCall, activeVc, knownUsers, dms, servers]);
const handleSaveProfile = (newName, newAvatar, newUsername) => {
const updatedProfile = { ...profile, displayName: newName, avatar: newAvatar, username: newUsername || profile.username };
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
const existingIndex = accounts.findIndex(a => a.seedHex === profile.seedHex);
if (existingIndex >= 0) {
accounts[existingIndex] = updatedProfile;
localStorage.setItem('pear_saved_accounts', JSON.stringify(accounts));
}
localStorage.setItem('pear_discord_identity', JSON.stringify(updatedProfile));
setProfile(updatedProfile);
network.updateProfile(newName, newAvatar, newUsername);
setIsSettingsOpen(false);
};
const handleCreateServer = async (name, icon, allowAnyone) => {
const newServer = await network.createServer(name, icon, allowAnyone, false);
setIsCreateServerOpen(false);
setActiveView(newServer.topicHex);
};
const handleCreateGroup = async (name, members) => {
const gc = await network.createServer(name, null, true, true);
for (const key of members) {
await network.sendGroupChatAdd(key, gc.topicHex);
}
setIsCreateGroupOpen(false);
setActiveView('dms');
setActiveDm(gc.topicHex);
};
const endCall = () => {
if (activeCall) {
if (activeCall.status === 'ringing' && activeCall.isCaller) {
network.sendWebRTCSignal(activeCall.targetKey, { type: 'webrtc-cancel' });
} else {
network.sendWebRTCSignal(activeCall.targetKey, { type: 'webrtc-end' });
}
if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
setActiveCall(null);
}
};
const startCall = (targetKey, callType = 'voice') => {
if (activeVc) {
network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
setVcStates(prev => {
const serverVCS = prev[activeVc.serverId];
if (!serverVCS) return prev;
const channelVCS = serverVCS[activeVc.channelId];
if (!channelVCS) return prev;
const newChannelVCS = { ...channelVCS };
delete newChannelVCS[myKey];
return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
});
setActiveVc(null);
}
const targetProfile = dms[targetKey]?.profile || knownUsers.find(u => u.key === targetKey) || { displayName: 'Unknown' };
setActiveCall({ targetKey, profile: targetProfile, status: 'ringing', isCaller: true, callType });
setShowChatInCall(false);
network.sendWebRTCSignal(targetKey, { type: 'webrtc-init', callType });
if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current);
callTimeoutRef.current = setTimeout(() => {
setActiveCall(current => {
if (current && current.targetKey === targetKey && current.status === 'ringing') {
network.sendWebRTCSignal(targetKey, { type: 'webrtc-cancel' });
return null;
}
return current;
});
}, 30000);
};
const startGroupCall = (channel, callType = 'voice') => {
if (activeVc) {
network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
setVcStates(prev => {
const serverVCS = prev[activeVc.serverId];
if (!serverVCS) return prev;
const channelVCS = serverVCS[activeVc.channelId];
if (!channelVCS) return prev;
const newChannelVCS = { ...channelVCS };
delete newChannelVCS[myKey];
return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
});
setActiveVc(null);
}
network.sendEphemeral({ type: 'webrtc-group-ring', channel, callerName: profile.displayName, callType });
setActiveGroupCall({ channel, callType });
setShowChatInCall(false);
};
const handleJoinVC = (channelId) => {
if (activeCall) endCall();
if (activeGroupCall) setActiveGroupCall(null);
if (activeVc) {
network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
setVcStates(prev => {
const serverVCS = prev[activeVc.serverId];
if (!serverVCS) return prev;
const channelVCS = serverVCS[activeVc.channelId];
if (!channelVCS) return prev;
const newChannelVCS = { ...channelVCS };
delete newChannelVCS[myKey];
return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
});
}
setActiveVc({ serverId: activeView, channelId });
setShowChatInCall(false);
};
const acceptCall = () => {
if (activeVc) {
network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
setVcStates(prev => {
const serverVCS = prev[activeVc.serverId];
if (!serverVCS) return prev;
const channelVCS = serverVCS[activeVc.channelId];
if (!channelVCS) return prev;
const newChannelVCS = { ...channelVCS };
delete newChannelVCS[myKey];
return { ...prev, [activeVc.serverId]: { ...serverVCS, [activeVc.channelId]: newChannelVCS } };
});
setActiveVc(null);
}
if (incomingCall.isGroup) {
setActiveGroupCall({ channel: incomingCall.channel, callType: incomingCall.callType });
setActiveView('dms');
setActiveDm(incomingCall.channel);
} else {
setActiveCall({ targetKey: incomingCall.targetKey, profile: incomingCall.profile, status: 'connecting', isCaller: false, callType: incomingCall.callType });
setActiveView('dms');
setActiveDm(incomingCall.targetKey);
network.sendWebRTCSignal(incomingCall.targetKey, { type: 'webrtc-accept' });
}
setShowChatInCall(false);
setIncomingCall(null);
};
const declineCall = () => {
if (!incomingCall.isGroup) {
network.sendWebRTCSignal(incomingCall.targetKey, { type: 'webrtc-decline' });
}
setIncomingCall(null);
};
const handleReturnToCall = () => {
if (activeCall) {
setActiveView('dms');
setActiveDm(activeCall.targetKey);
} else if (activeGroupCall) {
setActiveView('dms');
setActiveDm(activeGroupCall.channel);
} else if (activeVc) {
setActiveView(activeVc.serverId);
}
setShowChatInCall(false);
};
const unreadCounts = {};
messages.forEach(m => {
const channelId = m.recipient ? (m.sender === myKey ? m.recipient : m.sender) : m.channel;
if (m.sender !== myKey && m.timestamp > (lastRead[channelId] || 0)) {
unreadCounts[channelId] = (unreadCounts[channelId] || 0) + 1;
}
});
const isViewingCallDM = activeCall && activeView === 'dms' && activeDm === activeCall.targetKey;
const isViewingGroupCall = activeGroupCall && activeView === 'dms' && activeDm === activeGroupCall.channel;
const isViewingVC = activeVc && activeView === activeVc.serverId;
const showCallView = (isViewingCallDM || isViewingGroupCall || isViewingVC) && !showChatInCall;
const isGroupChat = activeView === 'dms' && servers.some(s => s.topicHex === activeDm && s.isGroupChat);
const inviteServerObj = servers.find(s => s.topicHex === inviteModalServer);
return (
<div className="flex h-full w-full bg-base font-sans overflow-hidden relative">
<Sidebar
activeView={activeView}
setActiveView={setActiveView}
servers={servers}
myKey={myKey}
onOpenCreateServer={() => setIsCreateServerOpen(true)}
onLeaveServer={(topicHex) => {
network.leaveServer(topicHex);
if (activeView === topicHex) setActiveView('dms');
}}
/>
{activeView === 'dms' ? (
<DMList
activeChannel={activeDm}
setActiveChannel={(ch) => {
setActiveDm(ch);
setLastRead(prev => ({ ...prev,[ch]: Date.now() }));
}}
myKey={myKey}
profile={profile}
unreadCounts={unreadCounts}
onOpenSettings={() => setIsSettingsOpen(true)}
dms={dms}
servers={servers}
onlinePeers={onlinePeers}
typingUsers={typingUsers}
activeCall={activeCall || activeGroupCall || activeVc}
onReturnToCall={handleReturnToCall}
onOpenCreateGroup={() => setIsCreateGroupOpen(true)}
onLeaveGroup={(topicHex) => {
network.leaveServer(topicHex);
if (activeDm === topicHex) setActiveDm('friends');
}}
onDeleteGroup={(topicHex) => {
network.deleteServer(topicHex);
if (activeDm === topicHex) setActiveDm('friends');
}}
isNetworkOnline={isNetworkOnline}
/>
) : (
<ChannelList
activeChannel={activeChannel}
setActiveChannel={(ch) => {
setActiveChannel(ch);
const netId = `${activeView}-${ch}`;
setLastRead(prev => ({ ...prev,[netId]: Date.now() }));
}}
myKey={myKey}
profile={profile}
unreadCounts={unreadCounts}
onOpenSettings={() => setIsSettingsOpen(true)}
activeView={activeView}
servers={servers}
serverMembers={serverMembers}
onlinePeers={onlinePeers}
knownUsers={knownUsers}
isSyncing={isSyncing}
onOpenInvite={() => setInviteModalServer(activeView)}
onOpenServerSettings={() => setSettingsModalServer(activeView)}
activeCall={activeCall || activeGroupCall || activeVc}
onReturnToCall={handleReturnToCall}
vcStates={vcStates}
activeVc={activeVc}
onJoinVC={handleJoinVC}
isNetworkOnline={isNetworkOnline}
/>
)}
{/* Main Content Area */}
<div className="flex-1 relative overflow-hidden flex">
{/* Chat Area (Hidden if CallView is active) */}
<div className={`flex-1 flex flex-col ${showCallView ? 'hidden' : ''}`}>
{activeView === 'dms' && activeDm === 'friends' ? (
<FriendsView dms={dms} />
) : (
<ChatArea
activeView={activeView}
activeChannel={activeView === 'dms' ? activeDm : activeChannel}
messages={messages}
myKey={myKey}
profile={profile}
typingUsers={typingUsers}
readReceipts={readReceipts}
deliveredReceipts={deliveredReceipts}
onlinePeers={onlinePeers}
markChannelRead={(networkId) => setLastRead(prev => ({ ...prev,[networkId]: Date.now() }))}
dms={dms}
servers={servers}
onStartCall={(ch, type) => {
const isGC = servers.some(s => s.topicHex === ch && s.isGroupChat);
if (isGC) startGroupCall(ch, type);
else startCall(ch, type);
}}
activeCall={activeCall || (activeGroupCall ? { targetKey: activeGroupCall.channel } : null)}
onReturnToCall={() => setShowChatInCall(false)}
transfers={transfers}
onOpenInvite={(topicHex) => setInviteModalServer(topicHex)}
onToggleMembers={() => setShowMembersDrawer(!showMembersDrawer)}
/>
)}
</div>
{/* 1-on-1 Call View */}
{activeCall && (
<CallView
className={showCallView && isViewingCallDM ? 'flex-1 flex flex-col' : 'hidden'}
targetKey={activeCall.targetKey}
targetProfile={activeCall.profile}
myProfile={profile}
isCaller={activeCall.isCaller}
status={activeCall.status}
initialVideoOn={activeCall.callType === 'video'}
onClose={endCall}
onToggleChat={() => setShowChatInCall(true)}
onConnected={() => setActiveCall(prev => prev ? { ...prev, status: 'connected' } : null)}
/>
)}
{/* Group Call View (Used for both DMs and Server VCs) */}
{(activeGroupCall || activeVc) && (
<GroupCallView
className={showCallView && (isViewingGroupCall || isViewingVC) ? 'flex-1 flex flex-col' : 'hidden'}
channel={activeGroupCall?.channel || `${activeVc.serverId}-${activeVc.channelId}`}
serverTopicHex={activeVc?.serverId}
vcChannelId={activeVc?.channelId}
initialVideoOn={activeGroupCall?.callType === 'video'}
myKey={myKey}
myProfile={profile}
knownUsers={knownUsers}
onLocalStateChange={(muted, screenshare) => {
if (!activeVc) return;
setVcStates(prev => {
const serverVCS = prev[activeVc.serverId] || {};
const channelVCS = serverVCS[activeVc.channelId] || {};
return {
...prev,
[activeVc.serverId]: {
...serverVCS,
[activeVc.channelId]: {
...channelVCS,
[myKey]: { muted, screenshare, timestamp: Date.now() }
}
}
};
});
}}
onClose={() => {
if (activeGroupCall) setActiveGroupCall(null);
if (activeVc) {
network.sendEphemeral({ type: 'vc-leave', serverTopicHex: activeVc.serverId, channel: activeVc.channelId });
setVcStates(prev => {
const serverVCS = prev[activeVc.serverId];
if (!serverVCS) return prev;
const channelVCS = serverVCS[activeVc.channelId];
if (!channelVCS) return prev;
const newChannelVCS = { ...channelVCS };
delete newChannelVCS[myKey];
return { ...prev, [activeVc.serverId]: { ...serverVCS,[activeVc.channelId]: newChannelVCS } };
});
setActiveVc(null);
}
}}
onToggleChat={() => setShowChatInCall(true)}
/>
)}
{/* Members Drawer */}
<div className={`absolute top-0 right-0 bottom-0 w-64 bg-surface border-l border-base transform transition-transform duration-300 z-40 ${showMembersDrawer && (activeView !== 'dms' || isGroupChat) ? 'translate-x-0' : 'translate-x-full'}`}>
<OnlineUsers
onlinePeers={onlinePeers}
knownUsers={knownUsers}
dms={dms}
myKey={myKey}
profile={profile}
activeView={activeView === 'dms' ? activeDm : activeView}
servers={servers}
serverMembers={serverMembers}
onClose={() => setShowMembersDrawer(false)}
/>
</div>
</div>
{isSettingsOpen && (
<ProfileSettingsModal
profile={profile}
myKey={myKey}
onClose={() => setIsSettingsOpen(false)}
onSave={handleSaveProfile}
onLogout={onLogout}
dms={dms}
servers={servers}
knownUsers={knownUsers}
updateState={updateState}
simulatedProgress={simulatedProgress}
triggerRestart={triggerRestart}
/>
)}
{isCreateServerOpen && (
<CreateServerModal onClose={() => setIsCreateServerOpen(false)} onSave={handleCreateServer} />
)}
{isCreateGroupOpen && (
<CreateGroupModal
onClose={() => setIsCreateGroupOpen(false)}
onSave={handleCreateGroup}
dms={dms}
/>
)}
{inviteModalServer && (
<InviteModal
onClose={() => setInviteModalServer(null)}
serverTopicHex={inviteModalServer}
dms={dms}
serverMembers={serverMembers}
isGroupChat={inviteServerObj?.isGroupChat}
/>
)}
{settingsModalServer && (
<ServerSettingsModal
onClose={() => setSettingsModalServer(null)}
activeServerObj={servers.find(s => s.topicHex === settingsModalServer)}
onDeleteServer={() => {
network.deleteServer(settingsModalServer);
setSettingsModalServer(null);
}}
/>
)}
{incomingCall && (
<IncomingCallModal
incomingCall={incomingCall}
onAccept={acceptCall}
onDecline={declineCall}
/>
)}
</div>
);
}

View File

@ -0,0 +1,111 @@
import React from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
export default function OnlineUsers({ onlinePeers, knownUsers, dms, myKey, profile, activeView, servers, serverMembers, onClose }) {
const handleSendRequest = (e, peer) => {
e.stopPropagation();
network.sendDMRequest(peer.key, { displayName: peer.displayName, username: peer.username, avatar: peer.avatar });
};
const isCustomServer = activeView !== 'dms';
const serverObj = isCustomServer ? servers.find(s => s.topicHex === activeView) : null;
const isGroupChat = serverObj?.isGroupChat;
const currentMembers = isCustomServer ? new Set(serverMembers[activeView] ||[]) : null;
if (isCustomServer && serverObj) {
currentMembers.add(serverObj.owner);
currentMembers.add(myKey);
}
const me = { key: myKey, displayName: profile.displayName, username: profile.username, avatar: profile.avatar };
const allOnlinePeers = [me, ...onlinePeers];
const filteredOnlinePeers = isCustomServer ? allOnlinePeers.filter(p => currentMembers.has(p.key)) : allOnlinePeers;
const onlineKeys = new Set(filteredOnlinePeers.map(p => p.key));
const offlineUsers =[];
if (isCustomServer && currentMembers) {
currentMembers.forEach(key => {
if (!onlineKeys.has(key) && key !== myKey) {
const known = knownUsers.find(u => u.key === key);
if (known) offlineUsers.push(known);
else offlineUsers.push({ key, displayName: 'Unknown User', username: 'unknown', avatar: null });
}
});
} else {
offlineUsers.push(...knownUsers.filter(u => !onlineKeys.has(u.key) && u.key !== myKey));
}
const renderUser = (peer, isOnline) => {
const dmState = dms[peer.key]?.status;
let isPlatformAdmin = peer.key === ADMIN_PUBLIC_KEY;
let isServerOwner = isCustomServer && !isGroupChat && serverObj?.owner === peer.key;
let isGroupCreator = isGroupChat && serverObj?.owner === peer.key;
return (
<div key={peer.key} className={`flex items-center justify-between group cursor-pointer hover:bg-panel p-2 rounded ${!isOnline ? 'opacity-60 hover:opacity-100' : ''}`}>
<div className="flex items-center gap-3 overflow-hidden">
<div className="relative shrink-0 w-8 h-8">
<div className={`w-full h-full rounded-md flex items-center justify-center text-white text-xs font-bold overflow-hidden ${peer.avatar ? 'bg-transparent' : 'bg-indigo-500'}`}>
{peer.avatar ? (
<img src={peer.avatar} alt="avatar" className="w-full h-full object-cover" />
) : (
peer.displayName.substring(0, 2).toUpperCase()
)}
</div>
<div className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[3px] border-surface ${isOnline ? 'bg-green-500' : 'bg-gray-500'}`}></div>
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-text text-sm truncate flex items-center gap-1">
{peer.displayName} {peer.key === myKey && <span className="text-muted text-xs ml-1">(You)</span>}
{isPlatformAdmin && <span title="Platform Admin" className="text-yellow-500 ml-1">👑</span>}
{isServerOwner && <span title="Hub Owner" className="text-yellow-500 ml-1">👑</span>}
{isGroupCreator && <span title="Group Creator" className="text-muted ml-1">👑</span>}
</span>
<span className="text-muted text-[10px] truncate">@{peer.username}</span>
</div>
</div>
{!dmState && peer.key !== myKey && (
<button
onClick={(e) => handleSendRequest(e, peer)}
className="opacity-0 group-hover:opacity-100 bg-accent hover:opacity-90 text-white p-1.5 rounded-full transition-all shrink-0"
title="Send Contact Request"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5c-2.2 0-4 1.8-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
</button>
)}
</div>
);
};
return (
<div className="w-full h-full flex flex-col p-4 overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<div className="text-xs font-bold text-muted uppercase">
{isGroupChat ? 'Members' : 'Online'} {filteredOnlinePeers.length}
</div>
<button onClick={onClose} className="text-muted hover:text-text">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div className="space-y-1 mb-6">
{filteredOnlinePeers.map(peer => renderUser(peer, true))}
</div>
{offlineUsers.length > 0 && (
<>
<div className="text-xs font-bold text-muted uppercase mb-2">
Offline {offlineUsers.length}
</div>
<div className="space-y-1">
{offlineUsers.map(peer => renderUser(peer, false))}
</div>
</>
)}
</div>
);
}

View File

@ -0,0 +1,586 @@
import React, { useState, useRef, useEffect } from 'react';
import { network, ADMIN_PUBLIC_KEY } from '../p2p/index.js';
function StorageSettings({ dms, servers, knownUsers }) {
const[stats, setStats] = useState(null);
const fetchStats = () => {
network.getStorageStats()
.then(setStats)
.catch(err => {
console.error("Failed to load storage stats:", err);
setStats({ total: 0, dms: {}, servers: {}, files:[] });
});
};
useEffect(() => {
fetchStats();
},[]);
const handlePrune = async (msgId) => {
try {
await network.pruneFile(msgId);
} catch (err) {
console.error("Failed to prune file:", err);
} finally {
fetchStats();
}
};
if (!stats) return <div className="text-text">Loading storage stats...</div>;
const formatBytes = (bytes) => {
if (!+bytes) return '0 Bytes';
const k = 1024;
const sizes =['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<div className="max-w-3xl">
<h2 className="text-xl font-bold text-text mb-6">Storage Management</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-2">Total Space Used by Media</h3>
<div className="text-3xl font-bold text-text mb-6">{formatBytes(stats.total)}</div>
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="text-muted uppercase text-xs font-bold mb-3">Whispers</h4>
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar pr-2">
{Object.entries(stats.dms).map(([key, size]) => {
const name = dms[key]?.profile?.displayName || knownUsers.find(u => u.key === key)?.displayName || 'Unknown';
return (
<div key={key} className="flex justify-between items-center bg-panel p-2 rounded">
<span className="text-sm text-text truncate pr-2">{name}</span>
<span className="text-sm font-mono text-muted shrink-0">{formatBytes(size)}</span>
</div>
);
})}
{Object.keys(stats.dms).length === 0 && <div className="text-sm text-muted">No media in Whispers</div>}
</div>
</div>
<div>
<h4 className="text-muted uppercase text-xs font-bold mb-3">Hubs</h4>
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar pr-2">
{Object.entries(stats.servers).map(([topicHex, data]) => {
const server = servers.find(s => s.topicHex === topicHex);
const name = server ? server.name : 'Unknown Hub';
return (
<div key={topicHex} className="flex flex-col bg-panel p-2 rounded gap-1">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-text truncate pr-2">{name}</span>
<span className="text-sm font-mono text-muted shrink-0">{formatBytes(data.total)}</span>
</div>
{Object.entries(data.channels).map(([ch, size]) => (
<div key={ch} className="flex justify-between items-center pl-2 border-l-2 border-surface">
<span className="text-xs text-muted truncate pr-2">#{ch}</span>
<span className="text-xs font-mono text-muted shrink-0">{formatBytes(size)}</span>
</div>
))}
</div>
);
})}
{Object.keys(stats.servers).length === 0 && <div className="text-sm text-muted">No media in Hubs</div>}
</div>
</div>
</div>
</div>
<div className="bg-surface rounded-lg p-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Large Files</h3>
<div className="space-y-2 max-h-64 overflow-y-auto custom-scrollbar pr-2">
{stats.files.slice(0, 50).map(file => (
<div key={file.id} className="flex justify-between items-center bg-panel p-3 rounded group">
<div className="flex flex-col overflow-hidden pr-4">
<span className="text-sm text-text font-medium truncate">{file.name}</span>
<span className="text-xs text-muted">{new Date(file.timestamp).toLocaleString()}</span>
</div>
<div className="flex items-center gap-4 shrink-0">
<span className="text-sm font-mono text-muted">{formatBytes(file.size)}</span>
<button
onClick={() => handlePrune(file.id)}
className="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white px-3 py-1.5 rounded text-xs font-bold transition-colors opacity-0 group-hover:opacity-100"
>
Delete Local Data
</button>
</div>
</div>
))}
{stats.files.length === 0 && <div className="text-sm text-muted text-center py-4">No large files found.</div>}
</div>
</div>
</div>
);
}
export default function ProfileSettingsModal({ profile, myKey, onClose, onSave, onLogout, dms, servers, knownUsers, updateState, triggerRestart }) {
const[activeTab, setActiveTab] = useState('account');
const[tempName, setTempName] = useState(profile.displayName);
const[tempAvatar, setTempAvatar] = useState(profile.avatar);
const[showSeed, setShowSeed] = useState(false);
const fileInputRef = useRef(null);
const isLegacyAccount = !profile.username || profile.username === 'unknown';
const[tempUsername, setTempUsername] = useState(isLegacyAccount ? '' : profile.username);
const[audioInputs, setAudioInputs] = useState([]);
const[audioOutputs, setAudioOutputs] = useState([]);
const[videoInputs, setVideoInputs] = useState([]);
const[selectedInput, setSelectedInput] = useState(localStorage.getItem('pear_audio_input') || 'default');
const [selectedOutput, setSelectedOutput] = useState(localStorage.getItem('pear_audio_output') || 'default');
const [selectedVideoInput, setSelectedVideoInput] = useState(localStorage.getItem('pear_video_input') || 'default');
const [autoRestart, setAutoRestart] = useState(localStorage.getItem('pear_auto_restart') !== 'false');
const [liveDecryption, setLiveDecryption] = useState(localStorage.getItem('pear_live_decryption') === 'true');
const defaultTheme = {
base: '#000000',
surface: '#0a0a0a',
panel: '#121212',
accent: '#5865F2',
text: '#f3f4f6',
muted: '#9ca3af'
};
const [theme, setTheme] = useState(() => JSON.parse(localStorage.getItem('peercord_theme')) || defaultTheme);
useEffect(() => {
if (activeTab === 'voice') {
navigator.mediaDevices.enumerateDevices().then(devices => {
setAudioInputs(devices.filter(d => d.kind === 'audioinput'));
setAudioOutputs(devices.filter(d => d.kind === 'audiooutput'));
setVideoInputs(devices.filter(d => d.kind === 'videoinput'));
}).catch(err => console.error("Failed to enumerate devices:", err));
}
},[activeTab]);
const handleInputSelect = (id) => {
setSelectedInput(id);
localStorage.setItem('pear_audio_input', id);
};
const handleOutputSelect = (id) => {
setSelectedOutput(id);
localStorage.setItem('pear_audio_output', id);
};
const handleVideoInputSelect = (id) => {
setSelectedVideoInput(id);
localStorage.setItem('pear_video_input', id);
};
const handleAutoRestartToggle = (e) => {
setAutoRestart(e.target.checked);
localStorage.setItem('pear_auto_restart', e.target.checked);
};
const handleThemeChange = (key, val) => {
const newTheme = { ...theme, [key]: val };
setTheme(newTheme);
document.documentElement.style.setProperty(`--color-${key}`, val);
localStorage.setItem('peercord_theme', JSON.stringify(newTheme));
};
const resetTheme = () => {
setTheme(defaultTheme);
Object.entries(defaultTheme).forEach(([key, val]) => {
document.documentElement.style.setProperty(`--color-${key}`, val);
});
localStorage.setItem('peercord_theme', JSON.stringify(defaultTheme));
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_SIZE = 128;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_SIZE) {
height *= MAX_SIZE / width;
width = MAX_SIZE;
}
} else {
if (height > MAX_SIZE) {
width *= MAX_SIZE / height;
height = MAX_SIZE;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const mimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const dataUrl = canvas.toDataURL(mimeType, 0.8);
setTempAvatar(dataUrl);
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
const handleSave = () => {
if (tempName.trim() === '') return;
let finalUsername = profile.username;
if (isLegacyAccount) {
finalUsername = tempUsername.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
if (!finalUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
}
onSave(tempName.trim(), tempAvatar, finalUsername);
};
const copySeed = () => {
if (profile.seedHex) navigator.clipboard.writeText(profile.seedHex);
};
const handleWipeData = async () => {
if (window.confirm("WARNING: Are you absolutely sure you want to wipe all data? \n\nThis will permanently delete your identity, messages, contacts, and hubs you've joined or created. The app will close immediately after. This cannot be undone!")) {
await network.wipeAllData();
}
};
return (
<div className="absolute inset-0 z-50 flex bg-base">
{/* Sidebar */}
<div className="w-60 bg-surface flex flex-col py-14 px-4 items-end shrink-0 border-r border-panel">
<div className="w-48 flex flex-col gap-1">
<div className="text-xs font-bold text-muted uppercase px-2 mb-1">User Settings</div>
<button
onClick={() => setActiveTab('account')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'account' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
My Account
</button>
<button
onClick={() => setActiveTab('appearance')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'appearance' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
Appearance
</button>
<button
onClick={() => setActiveTab('voice')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'voice' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
Voice & Video
</button>
<button
onClick={() => setActiveTab('storage')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'storage' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
Storage Management
</button>
<button
onClick={() => setActiveTab('settings')}
className={`text-left px-3 py-1.5 rounded text-sm font-medium ${activeTab === 'settings' ? 'bg-panel text-text' : 'text-muted hover:bg-panel/50 hover:text-text'}`}
>
App Settings
</button>
<div className="w-full h-[1px] bg-panel my-2"></div>
<button
onClick={onLogout}
className="text-left px-3 py-1.5 rounded text-sm font-medium text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
Log Out
</button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 bg-base py-14 px-10 relative overflow-y-auto">
<button
onClick={onClose}
className="absolute top-10 right-10 w-8 h-8 flex items-center justify-center rounded-full border-2 border-muted text-muted hover:bg-surface hover:text-text transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
{activeTab === 'account' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">My Account</h2>
<div className="bg-surface rounded-lg p-4 mb-6">
<div className="flex items-center gap-6">
<div
className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold cursor-pointer relative group overflow-hidden shrink-0 ${tempAvatar ? 'bg-transparent' : 'bg-indigo-500'}`}
onClick={() => fileInputRef.current?.click()}
>
{tempAvatar ? (
<img src={tempAvatar} alt="avatar" className="w-full h-full object-cover" />
) : (
tempName.substring(0, 2).toUpperCase()
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] uppercase tracking-wider text-white">Change</span>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/png, image/jpeg"
className="hidden"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-bold text-muted uppercase mb-2">Display Name</label>
<input
type="text"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent mb-4"
maxLength={24}
/>
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
{isLegacyAccount ? (
<input
type="text"
value={tempUsername}
onChange={(e) => setTempUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_.]/g, ''))}
className="w-full bg-panel text-text rounded p-2 outline-none focus:ring-1 focus:ring-accent text-sm font-mono"
placeholder="Set your username..."
maxLength={24}
/>
) : (
<input
type="text"
value={'@' + profile.username}
readOnly
className="w-full bg-panel text-muted rounded p-2 outline-none text-sm font-mono cursor-not-allowed"
/>
)}
</div>
</div>
</div>
<div className="bg-surface rounded-lg p-6 mb-6 border border-yellow-900/50">
<h3 className="text-yellow-500 font-bold mb-2 uppercase text-xs">Account Seed (Private Key)</h3>
<p className="text-muted text-sm mb-4">
This 64-character seed is the only way to log back into your account if you switch devices. Do not share it with anyone!
</p>
<div className="flex gap-2">
<input
type={showSeed ? "text" : "password"}
value={profile.seedHex}
readOnly
className="w-full bg-panel text-text rounded p-2 outline-none text-sm font-mono"
/>
<button onClick={() => setShowSeed(!showSeed)} className="bg-panel hover:bg-base text-text px-4 rounded text-sm font-medium transition-colors border border-surface">
{showSeed ? 'Hide' : 'Reveal'}
</button>
<button onClick={copySeed} className="bg-accent hover:opacity-90 text-white px-4 rounded text-sm font-medium transition-opacity">
Copy
</button>
</div>
</div>
<div className="flex justify-end gap-3">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
Cancel
</button>
<button onClick={handleSave} className="bg-accent hover:opacity-90 text-white px-6 py-2 rounded text-sm font-medium transition-opacity">
Save Changes
</button>
</div>
</div>
)}
{activeTab === 'appearance' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">Appearance</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Theme Colors</h3>
<div className="grid grid-cols-2 gap-4">
{Object.entries(theme).map(([key, val]) => (
<div key={key} className="flex items-center justify-between bg-panel p-3 rounded">
<span className="text-sm text-text capitalize">{key}</span>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-muted">{val}</span>
<input
type="color"
value={val}
onChange={(e) => handleThemeChange(key, e.target.value)}
className="w-8 h-8 rounded cursor-pointer bg-transparent border-none p-0"
/>
</div>
</div>
))}
</div>
<div className="mt-6 flex justify-end">
<button onClick={resetTheme} className="text-sm text-red-400 hover:underline">Reset to Default Theme</button>
</div>
</div>
</div>
)}
{activeTab === 'voice' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">Voice & Video Settings</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Input Device (Microphone)</label>
<select
value={selectedInput}
onChange={(e) => handleInputSelect(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-1 focus:ring-accent appearance-none cursor-pointer"
>
{audioInputs.length === 0 && <option value="default">Default Microphone</option>}
{audioInputs.map(device => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Microphone ${device.deviceId.substring(0, 5)}...`}
</option>
))}
</select>
</div>
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Output Device (Speakers)</label>
<select
value={selectedOutput}
onChange={(e) => handleOutputSelect(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-1 focus:ring-accent appearance-none cursor-pointer"
>
{audioOutputs.length === 0 && <option value="default">Default Speakers</option>}
{audioOutputs.map(device => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Speaker ${device.deviceId.substring(0, 5)}...`}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Camera (Webcam)</label>
<select
value={selectedVideoInput}
onChange={(e) => handleVideoInputSelect(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-1 focus:ring-accent appearance-none cursor-pointer"
>
{videoInputs.length === 0 && <option value="default">Default Camera</option>}
{videoInputs.map(device => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Camera ${device.deviceId.substring(0, 5)}...`}
</option>
))}
</select>
</div>
</div>
</div>
)}
{activeTab === 'storage' && (
<StorageSettings dms={dms} servers={servers} knownUsers={knownUsers} />
)}
{activeTab === 'settings' && (
<div className="max-w-2xl">
<h2 className="text-xl font-bold text-text mb-6">App Settings</h2>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Direct Messages</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={liveDecryption}
onChange={(e) => {
setLiveDecryption(e.target.checked);
localStorage.setItem('pear_live_decryption', e.target.checked);
}}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Enable Live Decryption Animation</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
Visually animates the decryption of incoming end-to-end encrypted messages in real-time.
</p>
</div>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Update Status</h3>
{updateState === 'downloading' ? (
<div className="flex flex-col gap-2">
<span className="text-sm text-text">Downloading new version...</span>
<div className="w-full bg-base rounded-full h-2 overflow-hidden my-1 relative">
<div className="bg-accent h-2 rounded-full absolute top-0 left-0 w-1/2 animate-indeterminate"></div>
</div>
<span className="text-xs text-muted">Please wait...</span>
</div>
) : updateState === 'available' || updateState === 'countdown' ? (
<div className="flex flex-col gap-3">
<span className="text-sm text-green-500 font-bold">Update Ready to Install</span>
<button onClick={triggerRestart} className="bg-accent hover:opacity-90 text-white px-4 py-2 rounded text-sm font-medium transition-opacity w-fit">
Restart to Update
</button>
</div>
) : updateState === 'gossip_available' || updateState === 'gossip_countdown' ? (
<div className="flex flex-col gap-3">
<span className="text-sm text-green-500 font-bold">New Update Broadcasted!</span>
<span className="text-xs text-muted">Restart the app to connect to the new seeder and begin downloading.</span>
<button onClick={() => {
if (typeof window !== 'undefined' && window.require) {
window.require('electron').ipcRenderer.send('normal-restart');
} else {
window.location.reload();
}
}} className="bg-accent hover:opacity-90 text-white px-4 py-2 rounded text-sm font-medium transition-opacity w-fit">
Restart & Download
</button>
</div>
) : (
<span className="text-sm text-muted">App is up to date.</span>
)}
</div>
<div className="bg-surface rounded-lg p-6 mb-6">
<h3 className="text-muted uppercase text-xs font-bold mb-4">Updates</h3>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={autoRestart}
onChange={handleAutoRestartToggle}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Automatically restart to apply updates</span>
</div>
<p className="text-[10px] text-muted mt-1 ml-8">
If disabled, you will be prompted to restart manually. (Auto-restart is always paused during calls or file transfers).
</p>
</div>
<div className="bg-surface rounded-lg p-6 mb-6 border border-red-900/50">
<h3 className="text-red-500 font-bold mb-2 uppercase text-xs">Danger Zone</h3>
<p className="text-muted text-sm mb-4">
This will permanently delete all your local data, including your cryptographic identity, messages, contacts, and hubs you've joined or created. This action cannot be undone and you will lose access to everything.
</p>
<button
onClick={handleWipeData}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors"
>
Wipe All App Data
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
export default function ScreenShareModal({ onClose, onStart }) {
const[resolution, setResolution] = useState('1080');
const [fps, setFps] = useState('60');
const[activeTab, setActiveTab] = useState('screens');
const[sources, setSources] = useState({ screens:[], windows: [] });
const [selectedSource, setSelectedSource] = useState(null);
const[useNativePicker, setUseNativePicker] = useState(false);
const resolutions =[
{ value: '1080', label: '1080p', width: 1920, height: 1080 },
{ value: '720', label: '720p', width: 1280, height: 720 },
{ value: '480', label: '480p', width: 854, height: 480 },
{ value: '360', label: '360p', width: 640, height: 360 },
{ value: '240', label: '240p', width: 426, height: 240 },
{ value: '144', label: '144p', width: 256, height: 144 }
];
const framerates =[
{ value: '60', label: '60 FPS (Smoothest)' },
{ value: '30', label: '30 FPS (Standard)' },
{ value: '15', label: '15 FPS (Low Bandwidth)' }
];
useEffect(() => {
const fetchSources = async () => {
try {
if (typeof window !== 'undefined' && window.require) {
const { ipcRenderer } = window.require('electron');
const allSources = await ipcRenderer.invoke('get-desktop-sources');
const formattedSources = allSources.map(s => ({
id: s.id,
name: s.name,
thumbnail: { toDataURL: () => s.thumbnailDataURL }
}));
const screens = formattedSources.filter(s => s.id.startsWith('screen'));
const windows = formattedSources.filter(s => s.id.startsWith('window'));
setSources({ screens, windows });
if (screens.length > 0) setSelectedSource(screens[0].id);
return;
}
} catch (e) {
console.warn("desktopSources not available, falling back to native picker", e);
}
setUseNativePicker(true);
};
fetchSources();
},[]);
const handleStart = () => {
const selectedRes = resolutions.find(r => r.value === resolution);
const sourceId = useNativePicker ? 'native' : selectedSource;
if (!sourceId) return;
onStart(sourceId, selectedRes, parseInt(fps));
};
return (
<div className="absolute inset-0 z-50 bg-black/70 flex items-center justify-center backdrop-blur-sm">
<div className="bg-base rounded-lg shadow-2xl w-full max-w-4xl flex flex-col overflow-hidden border border-surface max-h-[90vh]">
<div className="p-6 border-b border-surface shrink-0">
<h2 className="text-xl font-bold text-text mb-2">Screen Share Settings</h2>
<p className="text-sm text-muted">
Configure your stream quality and select what you want to share.
</p>
</div>
<div className="flex flex-col md:flex-row flex-1 overflow-hidden min-h-[400px]">
{/* Left Side: Source Selection */}
<div className="flex-1 flex flex-col border-r border-surface bg-panel overflow-hidden">
{!useNativePicker ? (
<>
<div className="flex border-b border-surface shrink-0">
<button
onClick={() => { setActiveTab('screens'); setSelectedSource(sources.screens[0]?.id); }}
className={`flex-1 py-3 text-sm font-bold transition-colors ${activeTab === 'screens' ? 'text-text border-b-2 border-accent' : 'text-muted hover:text-text hover:bg-surface/50'}`}
>
Entire Screen
</button>
<button
onClick={() => { setActiveTab('windows'); setSelectedSource(sources.windows[0]?.id); }}
className={`flex-1 py-3 text-sm font-bold transition-colors ${activeTab === 'windows' ? 'text-text border-b-2 border-accent' : 'text-muted hover:text-text hover:bg-surface/50'}`}
>
Specific Window
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div className="grid grid-cols-2 gap-4">
{sources[activeTab].map(source => (
<div
key={source.id}
onClick={() => setSelectedSource(source.id)}
className={`flex flex-col gap-2 p-2 rounded cursor-pointer border-2 transition-colors ${selectedSource === source.id ? 'border-accent bg-accent/10' : 'border-transparent hover:bg-surface'}`}
>
<img src={source.thumbnail.toDataURL()} alt={source.name} className="w-full aspect-video object-cover rounded bg-black" />
<span className="text-xs text-text truncate text-center font-medium">{source.name}</span>
</div>
))}
{sources[activeTab].length === 0 && (
<div className="col-span-2 text-center text-muted py-8 text-sm">
No {activeTab} found.
</div>
)}
</div>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center gap-4">
<div className="w-16 h-16 rounded-full bg-accent/20 flex items-center justify-center text-accent">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</div>
<h3 className="text-text font-bold">Native Picker Required</h3>
<p className="text-sm text-muted">
Your environment requires using the system's native screen picker. Click "Start Sharing" to open it.
</p>
</div>
)}
</div>
{/* Right Side: Quality Settings */}
<div className="w-72 p-6 flex flex-col gap-6 bg-base shrink-0 overflow-y-auto">
<div className="bg-accent/10 border border-accent/30 rounded p-4 flex gap-3 items-start">
<svg className="text-accent shrink-0 mt-0.5" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<p className="text-xs text-text leading-relaxed opacity-90">
If your connection is slow, the system will automatically downgrade resolution to maintain framerate.
</p>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Resolution</label>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="w-full bg-panel text-text rounded p-2.5 outline-none focus:ring-1 focus:ring-accent appearance-none cursor-pointer border border-surface text-sm font-medium"
>
{resolutions.map(res => (
<option key={res.value} value={res.value}>{res.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-muted uppercase mb-2">Frame Rate</label>
<select
value={fps}
onChange={(e) => setFps(e.target.value)}
className="w-full bg-panel text-text rounded p-2.5 outline-none focus:ring-1 focus:ring-accent appearance-none cursor-pointer border border-surface text-sm font-medium"
>
{framerates.map(f => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
</div>
</div>
<div className="p-4 bg-base flex justify-end gap-3 border-t border-surface shrink-0">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
Cancel
</button>
<button
onClick={handleStart}
disabled={!useNativePicker && !selectedSource}
className="bg-accent hover:opacity-90 text-white px-6 py-2 rounded text-sm font-medium transition-opacity flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
Start Sharing
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { network } from '../p2p/index.js';
export default function ServerInviteCard({ invite, joinedServers }) {
const { serverName, serverIcon, serverTopicHex, inviterName, serverOwner, allowAnyoneToInvite, isGroupChat, channels } = invite;
const[isJoined, setIsJoined] = useState(joinedServers.some(s => s.topicHex === serverTopicHex));
const handleJoin = () => {
if (isJoined) return;
network.joinServer(serverTopicHex, serverName, serverIcon, serverOwner, allowAnyoneToInvite, isGroupChat, channels);
setIsJoined(true);
};
return (
<div className="bg-surface rounded-lg p-4 max-w-sm w-full my-2 shadow-lg border border-panel">
<div className="text-xs font-bold text-muted uppercase mb-2">
You've been invited to join a {isGroupChat ? 'Group Whisper' : 'Hub'}
</div>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-md flex items-center justify-center text-white text-lg font-bold shrink-0 overflow-hidden ${serverIcon ? 'bg-transparent' : 'bg-indigo-500'}`}>
{serverIcon ? (
<img src={serverIcon} alt="icon" className="w-full h-full object-cover" />
) : (
serverName.substring(0, 2).toUpperCase()
)}
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<span className="font-bold text-text truncate">{serverName}</span>
<span className="text-xs text-muted truncate">Invited by {inviterName}</span>
</div>
<button
onClick={handleJoin}
disabled={isJoined}
className={`px-4 py-2 rounded text-sm font-medium transition-colors shrink-0 ${isJoined ? 'bg-green-600 text-white cursor-not-allowed' : 'bg-accent hover:opacity-90 text-white'}`}
>
{isJoined ? 'Joined' : 'Join'}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,124 @@
import React, { useState, useRef } from 'react';
import { network } from '../p2p/index.js';
export default function ServerSettingsModal({ onClose, activeServerObj, onDeleteServer }) {
const [serverName, setServerName] = useState(activeServerObj.name || '');
const [serverIcon, setServerIcon] = useState(activeServerObj.icon || null);
const[allowAnyone, setAllowAnyone] = useState(activeServerObj.allowAnyoneToInvite);
const fileInputRef = useRef(null);
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_SIZE = 128;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_SIZE) { height *= MAX_SIZE / width; width = MAX_SIZE; }
} else {
if (height > MAX_SIZE) { width *= MAX_SIZE / height; height = MAX_SIZE; }
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
setServerIcon(dataUrl);
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
const handleSave = () => {
if (serverName.trim() === '') return;
network.updateServerSettings(activeServerObj.topicHex, serverName.trim(), serverIcon, allowAnyone);
onClose();
};
const handleDelete = () => {
if (window.confirm("Are you sure you want to completely delete this hub? All members will be removed and message history will be permanently wiped for everyone. This cannot be undone.")) {
onDeleteServer();
}
};
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/70" onClick={onClose}>
<div className="bg-surface rounded-lg shadow-xl w-full max-w-md flex flex-col p-6 max-h-[90vh] overflow-y-auto border border-panel" onClick={e => e.stopPropagation()}>
<h2 className="text-2xl font-bold text-center text-text mb-2">Hub Settings</h2>
<div className="flex flex-col items-center gap-4 mt-2">
<div
className={`w-24 h-24 rounded-md flex items-center justify-center text-white text-3xl font-bold cursor-pointer relative group overflow-hidden shrink-0 border-2 border-dashed border-muted hover:border-text ${serverIcon ? 'bg-transparent border-solid' : 'bg-panel'}`}
onClick={() => fileInputRef.current?.click()}
>
{serverIcon ? (
<img src={serverIcon} alt="hub icon" className="w-full h-full object-cover" />
) : (
<div className="text-center text-xs text-muted flex flex-col items-center gap-1">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
Change
</div>
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] uppercase tracking-wider text-white">Upload</span>
</div>
<input type="file" ref={fileInputRef} onChange={handleImageUpload} accept="image/png, image/jpeg" className="hidden" />
</div>
<div className="w-full">
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Hub Name</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent mb-4"
placeholder="e.g. My Cool Club"
maxLength={32}
/>
<label className="block text-xs font-bold text-muted uppercase mb-2 text-left">Invite Permissions</label>
<div className="flex items-center gap-3 bg-panel p-3 rounded">
<input
type="checkbox"
checked={allowAnyone}
onChange={(e) => setAllowAnyone(e.target.checked)}
className="w-5 h-5 accent-accent cursor-pointer"
/>
<span className="text-sm text-text">Anyone can invite people to this hub</span>
</div>
<p className="text-[10px] text-muted mt-1 mb-4">If unchecked, only you (the Admin) can send invites.</p>
<div className="bg-panel rounded p-4 border border-red-900/50 mt-2">
<h3 className="text-red-500 font-bold mb-2 uppercase text-xs">Danger Zone</h3>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors w-full"
>
Delete Hub
</button>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 mt-2">
<button onClick={onClose} className="text-text hover:underline text-sm font-medium px-4 py-2">
Cancel
</button>
<button onClick={handleSave} disabled={!serverName.trim()} className="bg-accent hover:opacity-90 text-white px-6 py-2.5 rounded text-sm font-medium transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
Save Changes
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import { generateIdentitySeed, network } from '../p2p/index.js';
import logo from '../../assets/icon.png';
export default function SetupScreen({ setProfile }) {
const [view, setView] = useState('saved');
const [savedAccounts, setSavedAccounts] = useState([]);
const [displayName, setDisplayName] = useState('');
const[username, setUsername] = useState('');
const [seedHex, setSeedHex] = useState('');
const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
setSavedAccounts(accounts);
if (accounts.length === 0) setView('signup');
},[]);
const saveAccountToStorage = (profile) => {
const accounts = JSON.parse(localStorage.getItem('pear_saved_accounts') || '[]');
const existingIndex = accounts.findIndex(a => a.seedHex === profile.seedHex);
if (existingIndex >= 0) {
accounts[existingIndex] = profile;
} else {
accounts.push(profile);
}
localStorage.setItem('pear_saved_accounts', JSON.stringify(accounts));
};
const handleSignup = async (e) => {
e.preventDefault();
if (!displayName.trim() || !username.trim()) return;
const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
if (!cleanUsername) return alert("Invalid username. Use only letters, numbers, underscores, and periods.");
setIsChecking(true);
try {
const isAvailable = await network.checkUsernameAvailable(cleanUsername);
if (!isAvailable) {
alert("This username is currently in use by an online peer. Please choose another one.");
setIsChecking(false);
return;
}
} catch (err) {
console.error("Username check failed:", err);
}
setIsChecking(false);
const newSeedHex = generateIdentitySeed();
const profile = { displayName: displayName.trim(), username: cleanUsername, seedHex: newSeedHex, avatar: null };
saveAccountToStorage(profile);
localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
setProfile(profile);
};
const handleLogin = (e) => {
e.preventDefault();
if (!seedHex.trim() || !displayName.trim() || !username.trim()) return;
const cleanUsername = username.trim().toLowerCase().replace(/[^a-z0-9_.]/g, '');
const profile = { displayName: displayName.trim(), username: cleanUsername, seedHex: seedHex.trim(), avatar: null };
saveAccountToStorage(profile);
localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
setProfile(profile);
};
const handleSavedLogin = (profile) => {
localStorage.setItem('pear_discord_identity', JSON.stringify(profile));
setProfile(profile);
};
return (
<div className="flex h-full w-full items-center justify-center bg-base font-sans">
<div className="bg-surface p-8 rounded-lg shadow-xl w-96 flex flex-col items-center border border-panel">
<img src={logo} alt="Logo" className="w-16 h-16 rounded-md mb-6 shadow-lg object-cover" />
{view === 'saved' && (
<div className="w-full">
<h1 className="text-2xl font-bold text-text mb-2 text-center">Welcome Back</h1>
<p className="text-muted text-sm text-center mb-6">Select an account to log in.</p>
<div className="space-y-2 mb-6 max-h-48 overflow-y-auto custom-scrollbar pr-2">
{savedAccounts.map((acc, i) => (
<div
key={i}
onClick={() => handleSavedLogin(acc)}
className="flex items-center gap-3 p-3 bg-panel hover:bg-base rounded cursor-pointer transition-colors border border-surface"
>
<div className="w-10 h-10 rounded-md bg-indigo-500 flex items-center justify-center text-white font-bold overflow-hidden shrink-0">
{acc.avatar ? <img src={acc.avatar} className="w-full h-full object-cover" /> : acc.displayName.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-text font-bold truncate">{acc.displayName}</span>
<span className="text-xs text-muted truncate">@{acc.username}</span>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-2">
<button onClick={() => setView('signup')} className="w-full bg-accent hover:opacity-90 text-white font-bold py-2.5 rounded transition-opacity">
Create New Account
</button>
<button onClick={() => setView('login')} className="w-full bg-panel hover:bg-base text-text font-bold py-2.5 rounded transition-colors border border-surface">
Login with Account Seed
</button>
</div>
</div>
)}
{view === 'signup' && (
<form onSubmit={handleSignup} className="w-full">
<h1 className="text-2xl font-bold text-text mb-2 text-center">Create Account</h1>
<p className="text-muted text-sm text-center mb-6">Your cryptographic identity will be generated automatically.</p>
<div className="mb-4">
<label className="block text-xs font-bold text-muted uppercase mb-2">Display Name</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. Satoshi"
maxLength={24}
disabled={isChecking}
/>
</div>
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_.]/g, ''))}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. satoshi_nakamoto"
maxLength={24}
disabled={isChecking}
/>
</div>
<button type="submit" disabled={isChecking} className="w-full bg-accent hover:opacity-90 text-white font-bold py-3 rounded transition-opacity disabled:opacity-50 flex justify-center items-center gap-2">
{isChecking ? (
<>
<span className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
Checking Username...
</>
) : (
'Create Identity & Join'
)}
</button>
<div className="mt-4 flex justify-between text-sm">
<button type="button" onClick={() => setView('login')} className="text-accent hover:underline">Have a seed?</button>
{savedAccounts.length > 0 && <button type="button" onClick={() => setView('saved')} className="text-muted hover:underline">Saved Accounts</button>}
</div>
</form>
)}
{view === 'login' && (
<form onSubmit={handleLogin} className="w-full">
<h1 className="text-2xl font-bold text-text mb-2 text-center">Login with Seed</h1>
<p className="text-muted text-sm text-center mb-6">Paste your 64-character private key to restore your account.</p>
<div className="mb-4">
<label className="block text-xs font-bold text-muted uppercase mb-2">Account Seed (Private Key)</label>
<input
type="password"
value={seedHex}
onChange={(e) => setSeedHex(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent font-mono text-sm"
placeholder="Paste 64-character hex seed..."
/>
</div>
<div className="mb-4">
<label className="block text-xs font-bold text-muted uppercase mb-2">Display Name</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. Satoshi"
maxLength={24}
/>
</div>
<div className="mb-6">
<label className="block text-xs font-bold text-muted uppercase mb-2">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_.]/g, ''))}
className="w-full bg-panel text-text rounded p-3 outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. satoshi_nakamoto"
maxLength={24}
/>
</div>
<button type="submit" className="w-full bg-accent hover:opacity-90 text-white font-bold py-3 rounded transition-opacity">
Restore & Login
</button>
<div className="mt-4 flex justify-between text-sm">
<button type="button" onClick={() => setView('signup')} className="text-accent hover:underline">Create Account</button>
{savedAccounts.length > 0 && <button type="button" onClick={() => setView('saved')} className="text-muted hover:underline">Saved Accounts</button>}
</div>
</form>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react';
import logo from '../../assets/iconWhite.png';
export default function Sidebar({ activeView, setActiveView, servers, myKey, onOpenCreateServer, onLeaveServer }) {
const[contextMenu, setContextMenu] = useState(null);
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
},[contextMenu]);
const publicServers = servers.filter(s => s.isGroupChat !== true);
const NavItem = ({ id, icon, name, isImage, imageClass, onClick, onContextMenu }) => {
const isActive = activeView === id;
return (
<div className="relative group flex justify-center w-full mb-2">
<div className={`absolute left-0 top-1/2 -translate-y-1/2 w-1 bg-text rounded-r-full transition-all duration-300 ${isActive ? 'h-10' : 'h-0 group-hover:h-5'}`}></div>
<div
onClick={onClick}
onContextMenu={onContextMenu}
className={`w-12 h-12 flex items-center justify-center font-bold shrink-0 overflow-hidden transition-all duration-300 cursor-pointer ${
isActive
? 'bg-accent text-white rounded-[16px]'
: 'bg-panel text-text rounded-[24px] hover:rounded-[16px] hover:bg-accent hover:text-white'
}`}
>
{isImage ? (
<img src={icon} alt={name} className={`${imageClass || 'w-full h-full object-cover'} pointer-events-none`} />
) : (
icon
)}
</div>
{/* Discord-style Tooltip */}
<div className="absolute left-[74px] top-1/2 -translate-y-1/2 flex items-center opacity-0 group-hover:opacity-100 pointer-events-none z-50 scale-95 group-hover:scale-100 transition-all origin-left duration-150">
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
{name}
</div>
</div>
</div>
);
};
return (
<div className="w-[72px] bg-base flex flex-col py-3 items-center shrink-0 overflow-y-auto hide-scrollbar border-r border-surface relative z-20">
{contextMenu && (
<div className="fixed inset-0 z-50" onClick={() => setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}>
<div
className="absolute bg-panel border border-surface shadow-xl rounded py-1.5 w-40 flex flex-col"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-sm text-red-500 hover:bg-red-500 hover:text-white transition-colors"
onClick={() => {
onLeaveServer(contextMenu.topicHex);
setContextMenu(null);
}}
>
Leave Hub
</button>
</div>
</div>
)}
<NavItem
id="dms"
name="Whispers"
isImage={true}
icon={logo}
imageClass="w-7 h-7 object-contain"
onClick={() => setActiveView('dms')}
/>
<div className="w-8 h-[2px] bg-surface rounded-full my-2 shrink-0"></div>
{publicServers.map(server => (
<NavItem
key={server.topicHex}
id={server.topicHex}
name={server.name}
isImage={!!server.icon}
icon={server.icon || server.name.substring(0, 2).toUpperCase()}
onClick={() => setActiveView(server.topicHex)}
onContextMenu={(e) => {
e.preventDefault();
if (server.owner === myKey) return;
setContextMenu({ x: e.pageX, y: e.pageY, topicHex: server.topicHex });
}}
/>
))}
<div
onClick={onOpenCreateServer}
className="relative group flex justify-center w-full mt-2"
>
<div className="w-12 h-12 flex items-center justify-center font-bold shrink-0 overflow-hidden transition-all duration-300 cursor-pointer bg-panel text-accent rounded-[24px] hover:rounded-[16px] hover:bg-accent hover:text-white">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</div>
{/* Discord-style Tooltip */}
<div className="absolute left-[74px] top-1/2 -translate-y-1/2 flex items-center opacity-0 group-hover:opacity-100 pointer-events-none z-50 scale-95 group-hover:scale-100 transition-all origin-left duration-150">
<div className="w-0 h-0 border-y-[6px] border-y-transparent border-r-[6px] border-r-panel -mr-[1px]"></div>
<div className="bg-panel text-text text-[15px] font-bold py-1.5 px-3 rounded-md shadow-xl whitespace-nowrap">
Create Hub
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import logo from '../../assets/icon.png';
export default function TitleBar() {
const[isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
let cleanupIpc = null;
// Reliable IPC listener for Electron (.exe build)
if (typeof window !== 'undefined' && window.require) {
try {
const { ipcRenderer } = window.require('electron');
const handleWindowState = (e, isMax) => setIsMaximized(isMax);
ipcRenderer.on('window-state-changed', handleWindowState);
cleanupIpc = () => ipcRenderer.removeListener('window-state-changed', handleWindowState);
} catch (e) {}
}
// Multi-monitor resilient heuristic
const handleResize = () => {
const isMax = (window.outerHeight >= window.screen.availHeight * 0.85) ||
(window.outerWidth >= window.screen.availWidth * 0.85);
setIsMaximized(isMax);
};
window.addEventListener('resize', handleResize);
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
if (cleanupIpc) cleanupIpc();
};
},[]);
const performAction = async (action) => {
try {
if (typeof window !== 'undefined' && window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('window-action', action);
}
} catch (e) {
console.error("Failed to perform window action:", e);
}
};
const handleMinimize = () => performAction('minimize');
const handleMaximize = async () => {
if (isMaximized) {
await performAction('restore');
setIsMaximized(false);
} else {
await performAction('maximize');
setIsMaximized(true);
}
};
const handleClose = () => performAction('close');
return (
<div
className="h-7 bg-base flex justify-between items-center titlebar text-muted text-xs shrink-0 border-b border-surface"
style={{ WebkitAppRegion: 'drag' }}
>
<div className="pl-3 flex items-center gap-2 font-bold tracking-wide text-[11px] text-muted">
<img src={logo} alt="Logo" className="w-4 h-4 rounded-md object-cover" />
Peercord
{window.APP_VERSION && (
<div className="flex items-center gap-2 bg-surface px-2.5 py-0.5 rounded-full border border-panel ml-2">
<div
className="w-1.5 h-1.5 rounded-full"
style={{
backgroundColor: window.APP_VERSION_COLOR,
boxShadow: `0 0 6px ${window.APP_VERSION_COLOR}`
}}
></div>
<span className="font-mono text-[10px] text-muted font-bold pt-[1px]">v{window.APP_VERSION}</span>
</div>
)}
</div>
<div className="flex h-full titlebar-button" style={{ WebkitAppRegion: 'no-drag' }}>
<button
onClick={handleMinimize}
className="px-4 hover:bg-surface h-full flex items-center justify-center transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12"><rect fill="currentColor" width="10" height="1" x="1" y="6"></rect></svg>
</button>
<button
onClick={handleMaximize}
className="px-4 hover:bg-surface h-full flex items-center justify-center transition-colors"
>
{isMaximized ? (
<svg width="12" height="12" viewBox="0 0 12 12"><path fill="currentColor" d="M3 3v6h6V3H3zm1 1h4v4H4V4z"></path></svg>
) : (
<svg width="12" height="12" viewBox="0 0 12 12"><path fill="currentColor" d="M2 2v8h8V2H2zm1 1h6v6H3V3z"></path></svg>
)}
</button>
<button
onClick={handleClose}
className="px-4 hover:bg-red-500 hover:text-white h-full flex items-center justify-center transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12"><path fill="currentColor" d="M7.06 6L10 3.06 8.94 2 6 4.94 3.06 2 2 3.06 4.94 6 2 8.94 3.06 10 6 7.06 8.94 10 10 8.94 7.06 6z"></path></svg>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Default OLED Black & Blue Theme */
--color-base: #000000;
--color-surface: #0a0a0a;
--color-panel: #121212;
--color-accent: #5865F2;
--color-text: #f3f4f6;
--color-muted: #9ca3af;
}
html, body, #root {
height: 100%;
width: 100%;
background-color: var(--color-base);
color: var(--color-text);
}
.titlebar {
-webkit-app-region: drag;
user-select: none;
}
.titlebar-button {
-webkit-app-region: no-drag;
}
/* Global Custom Scrollbar Styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-surface) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-surface);
border-radius: 4px;
border: 1px solid var(--color-base);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-muted);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@keyframes typing-pulse {
0%, 60%, 100% {
transform: scale(0.7) translateY(0);
background-color: var(--color-muted);
}
30% {
transform: scale(1.2) translateY(-2px);
background-color: var(--color-text);
}
}
.typing-dot {
animation: typing-pulse 1.2s infinite ease-in-out;
}
/* Indeterminate Progress Bar Animation */
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.animate-indeterminate {
animation: indeterminate 1.5s infinite linear;
}

View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,112 @@
const b4a = window.require('b4a');
export async function handleData(network, peerKey, data, conn) {
try {
const parsed = JSON.parse(b4a.toString(data));
switch (parsed.type) {
case 'identity':
await handleIdentity(network, peerKey, parsed);
break;
case 'whois':
handleWhois(network, parsed, conn);
break;
case 'whois_reply':
handleWhoisReply(network, parsed);
break;
case 'ephemeral':
handleEphemeral(network, peerKey, parsed);
break;
default:
// Could be a standard message core, which is handled by replication, not this handler.
}
} catch (err) {
// Likely binary data from core replication, ignore.
}
}
async function handleIdentity(network, peerKey, parsed) {
const peerInfo = network.peers.get(peerKey);
if (!peerInfo) return;
peerInfo.displayName = parsed.displayName;
peerInfo.username = parsed.username;
peerInfo.avatar = parsed.avatar;
peerInfo.coreKey = parsed.coreKey;
const profileObj = { displayName: parsed.displayName, username: parsed.username, avatar: parsed.avatar };
network.knownProfiles.set(peerKey, profileObj);
if (network.profilesDb) await network.profilesDb.put(peerKey, profileObj);
if (network.coresDb && parsed.coreKey) await network.coresDb.put(peerKey, parsed.coreKey);
network._emitKnownProfiles();
if (parsed.username) {
const uname = parsed.username.toLowerCase();
network.userDirectory.set(uname, { pubKey: peerKey, profile: parsed });
network.dirDb.put(uname, { pubKey: peerKey, profile: parsed });
network._checkPendingRequests(uname, peerKey, parsed);
}
if (network.dms[peerKey]) {
network.dms[peerKey].profile = profileObj;
await network.db.put('dm:' + peerKey, network.dms[peerKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
if (network.onPeerUpdate) network.onPeerUpdate(network.getPeerList());
network._emitMessages();
await network.trackPeerCore(parsed.coreKey);
}
function handleWhois(network, parsed, conn) {
const uname = parsed.username;
if (network.userDirectory.has(uname)) {
const cached = network.userDirectory.get(uname);
const reply = b4a.from(JSON.stringify({ type: 'whois_reply', queryId: parsed.queryId, username: uname, pubKey: cached.pubKey, profile: cached.profile }));
conn.write(reply);
}
}
function handleWhoisReply(network, parsed) {
const cb = network.pendingWhois.get(parsed.queryId);
if (cb) cb({ pubKey: parsed.pubKey, profile: parsed.profile });
network.userDirectory.set(parsed.username, { pubKey: parsed.pubKey, profile: parsed.profile });
network.dirDb.put(parsed.username, { pubKey: parsed.pubKey, profile: parsed.profile });
network._checkPendingRequests(parsed.username, parsed.pubKey, parsed.profile);
}
function handleEphemeral(network, peerKey, parsed) {
const { payload } = parsed;
if (!payload) return;
if (payload.type === 'offline') {
const peerInfo = network.peers.get(peerKey);
if (peerInfo) {
network.peers.delete(peerKey);
try { peerInfo.conn.destroy(); } catch (e) {}
if (network.onPeerUpdate) network.onPeerUpdate(network.getPeerList());
}
}
if (payload.type.startsWith('webrtc-') || payload.type === 'voice_activity' || payload.type.startsWith('vc-')) {
for (const fn of network.webrtcListeners) fn(peerKey, payload);
}
if (payload.type === 'transfer_progress') {
const current = network.transfers[payload.id] || { progress: 0, speed: 0 };
if (payload.progress >= current.progress || payload.progress === 1) {
network.transfers[payload.id] = {
...current,
progress: payload.progress,
speed: payload.speed,
state: payload.progress >= 1 ? 'completed' : 'uploading'
};
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
}
}
if (network.onEphemeral) network.onEphemeral(peerKey, payload);
}

View File

@ -0,0 +1,618 @@
const b4a = window.require('b4a');
import { generateUUID, Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http } from './utils.js';
import * as Identity from './modules/identity.js';
import { handleData } from './handlers.js';
import { getAllMessages, processMessage, sendDMRequest, sendMessage, sendDM, sendEditMessage, sendDeleteMessage, acceptDMRequest, sendEphemeral, sendReadReceipt, sendDeliveredReceipt, sendOffline, sendTyping, _appendSignedMessage, _appendEncryptedMessage } from './modules/messaging.js';
import { createServer, joinServer, deleteServer, leaveServer, sendServerInvite, updateServerSettings, sendGroupChatAdd } from './modules/servers.js';
import { searchUser, queueFriendRequest, trackPeerCore } from './modules/discovery.js';
import { sendFile, sendDMFile, downloadFile } from './modules/files.js';
import { addWebRTCListener, removeWebRTCListener, sendWebRTCSignal } from './modules/webrtc.js';
class P2PNetwork {
constructor() {
this.swarm = null;
this.store = null;
this.localCore = null;
this.db = null;
this.serverDb = null;
this.dirDb = null;
this.pendingRequestsDb = null;
this.localFilesDb = null;
this.coresDb = null;
this.profilesDb = null;
this.coreKey = null;
this.myKey = null;
this.secretKey = null;
this.displayName = '';
this.username = '';
this.avatar = null;
this.storagePath = null;
this.peers = new Map();
this.peerCores = new Map();
this.knownProfiles = new Map();
this.userDirectory = new Map();
this.pendingWhois = new Map();
this.pendingFriendRequests = new Set();
this.messages = new Map();
this.deletedMessages = new Set();
this.dms = {};
this.servers =[];
this.serverMembers = {};
this.joinedTopics = new Set();
this.syncTimeout = null;
this._msgTimeout = null;
this.transfers = {};
this.webrtcListeners = new Set();
// Distributed Systems Ordering
this.logicalClock = 0;
this.timeOffset = 0;
// App State Tracking (Used to prevent auto-restarts during critical operations)
this.activeCalls = 0;
this.onInit = null;
this.onPeerUpdate = null;
this.onMessage = null;
this.onEphemeral = null;
this.onDMsUpdate = null;
this.onKnownProfilesUpdate = null;
this.onServersUpdate = null;
this.onServerMembersUpdate = null;
this.onSync = null;
this.onTransfersUpdate = null;
}
// Method Bindings
getAllMessages = () => getAllMessages(this);
processMessage = (msg) => processMessage(this, msg);
sendDMRequest = (targetKey, profile) => sendDMRequest(this, targetKey, profile);
acceptDMRequest = (targetKey) => acceptDMRequest(this, targetKey);
sendMessage = (channel, text) => sendMessage(this, channel, text);
sendDM = (targetKey, text) => sendDM(this, targetKey, text);
sendEditMessage = (targetId, newText) => sendEditMessage(this, targetId, newText);
sendDeleteMessage = (targetId) => sendDeleteMessage(this, targetId);
sendEphemeral = (payload) => sendEphemeral(this, payload);
sendReadReceipt = (channel, messageId) => sendReadReceipt(this, channel, messageId);
sendDeliveredReceipt = (channel, messageId) => sendDeliveredReceipt(this, channel, messageId);
sendOffline = () => sendOffline(this);
sendTyping = (channel) => sendTyping(this, channel);
pruneFile = (msgId) => this._pruneFile(msgId);
getStorageStats = () => this._getStorageStats();
_appendSignedMessage = (payloadObj) => _appendSignedMessage(this, payloadObj);
_appendEncryptedMessage = (targetKey, payloadObj) => _appendEncryptedMessage(this, targetKey, payloadObj);
_downloadFile = (msgId, fileMeta, isSender) => downloadFile(this, msgId, fileMeta, isSender);
_emitMessages() {
if (!this.onMessage) return;
if (this._msgTimeout) clearTimeout(this._msgTimeout);
this._msgTimeout = setTimeout(() => {
this.onMessage(this.getAllMessages());
this._msgTimeout = null;
}, 50);
}
_wipeLocalServerData = async (topicHex) => {
this.servers = this.servers.filter(s => s.topicHex !== topicHex);
if (this.serverDb) await this.serverDb.del(topicHex);
delete this.serverMembers[topicHex];
// Remove message history for this server/group chat
const msgsToDelete =[];
for (const [msgId, msg] of this.messages.entries()) {
const ch = msg.payload?.channel;
if (ch === topicHex || (ch && ch.startsWith(topicHex + '-'))) {
msgsToDelete.push(msgId);
}
}
let localDeleted =[];
if (typeof window !== 'undefined') {
localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
}
for (const msgId of msgsToDelete) {
const msg = this.messages.get(msgId);
if (msg) {
if (msg.localPath && fs && fs.existsSync(msg.localPath)) {
try { fs.unlinkSync(msg.localPath); } catch (e) {}
}
if (msg.payload?.file?.coreKey) {
try {
const core = this.store.get({ key: b4a.from(msg.payload.file.coreKey, 'hex') });
await core.ready();
await core.clear(0, core.length);
} catch (e) {}
}
this.deletedMessages.add(msgId);
this.messages.delete(msgId);
if (!localDeleted.includes(msgId)) localDeleted.push(msgId);
if (this.transfers[msgId]) delete this.transfers[msgId];
}
}
if (typeof window !== 'undefined' && msgsToDelete.length > 0) {
localStorage.setItem('pear_local_deleted_msgs', JSON.stringify(localDeleted));
}
if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers);
this._emitMessages();
this._emitServers();
this._emitServerMembers();
};
_reloadCores = async () => {
this._emitSync();
};
// Using spread arguments ensures all flags (like isGroupChat) are properly passed down
createServer = (...args) => createServer(this, ...args);
joinServer = (...args) => joinServer(this, ...args);
deleteServer = (...args) => deleteServer(this, ...args);
leaveServer = (...args) => leaveServer(this, ...args);
sendServerInvite = (...args) => sendServerInvite(this, ...args);
updateServerSettings = (...args) => updateServerSettings(this, ...args);
sendGroupChatAdd = (...args) => sendGroupChatAdd(this, ...args);
searchUser = (username) => searchUser(this, username);
queueFriendRequest = (username) => queueFriendRequest(this, username);
trackPeerCore = (coreKeyHex) => trackPeerCore(this, coreKeyHex);
sendFile = (...args) => sendFile(this, ...args);
sendDMFile = (...args) => sendDMFile(this, ...args);
addWebRTCListener = (fn) => addWebRTCListener(this, fn);
removeWebRTCListener = (fn) => removeWebRTCListener(this, fn);
sendWebRTCSignal = (target, payload) => sendWebRTCSignal(this, target, payload);
updateProfile = (name, avatar, username) => Identity.updateProfile(this, name, avatar, username);
_checkPendingRequests = (uname, pubKey, profile) => {
if (this.pendingFriendRequests.has(uname)) {
this.pendingFriendRequests.delete(uname);
this.pendingRequestsDb.del(uname);
this.sendDMRequest(pubKey, profile);
}
}
_emitKnownProfiles() {
if (this.onKnownProfilesUpdate) {
this.onKnownProfilesUpdate(Array.from(this.knownProfiles.entries()).map(([key, profile]) => ({ key, ...profile })));
}
}
_emitServers() {
if (this.onServersUpdate) this.onServersUpdate([...this.servers]);
}
_emitServerMembers() {
if (this.onServerMembersUpdate) {
const formatted = {};
for (const topic in this.serverMembers) {
formatted[topic] = Array.from(this.serverMembers[topic]);
}
this.onServerMembersUpdate(formatted);
}
}
_emitSync() {
if (this.onSync) this.onSync(true);
if (this.syncTimeout) clearTimeout(this.syncTimeout);
this.syncTimeout = setTimeout(() => {
if (this.onSync) this.onSync(false);
}, 500);
}
getBusyReasons() {
const reasons =[];
let activeUploads = 0;
let activeDownloads = 0;
let processing = 0;
for (const t of Object.values(this.transfers)) {
if (t.state === 'processing') {
processing++;
} else if (t.state === 'downloading') {
if (t.speed > 0 || (t.progress > 0 && t.progress < 1)) {
activeDownloads++;
}
} else if (t.state === 'uploading') {
if (t.speed > 0 || (t.progress > 0 && t.progress < 1)) {
activeUploads++;
}
}
}
if (processing > 0) reasons.push("Processing local files");
if (activeUploads > 0) reasons.push("Uploading files to peers");
if (activeDownloads > 0) reasons.push("Downloading files");
if (this.activeCalls > 0) reasons.push("Active voice/video call");
return reasons;
}
isBusy() {
return this.getBusyReasons().length > 0;
}
async _syncTimeWithServer() {
try {
if (!http) throw new Error("HTTP module not loaded");
await new Promise((resolve, reject) => {
// Using 1.1.1.1 bypasses DNS resolution entirely to prevent EAI_AGAIN on Linux VMs
const req = http.request({
hostname: '1.1.1.1',
method: 'HEAD',
port: 80,
timeout: 5000
}, (res) => {
const dateHeader = res.headers.date;
if (dateHeader) {
const serverTime = new Date(dateHeader).getTime();
const localTime = Date.now();
this.timeOffset = serverTime - localTime;
console.log(`[Time Sync] Offset calculated: ${this.timeOffset}ms`);
} else {
console.warn('[Time Sync] No date header found in response.');
}
resolve();
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Connection timed out'));
});
req.on('error', (err) => {
reject(err);
});
req.end();
});
} catch (err) {
console.warn('[Time Sync] Failed to reach time server, falling back to local system clock.', err.message || err);
this.timeOffset = 0;
}
}
async checkUsernameAvailable(username) {
const normalized = username.toLowerCase();
// Keep strict limits here as this is a temporary, single-purpose swarm
const tempSwarm = new Hyperswarm({ maxPeers: 3, maxClientConnections: 3, maxServerConnections: 0 });
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
let isTaken = false;
tempSwarm.on('connection', (conn) => {
isTaken = true;
conn.destroy();
});
tempSwarm.join(topic, { client: true, server: false });
// Wait up to 3 seconds to see if anyone responds on this topic
for (let i = 0; i < 30; i++) {
if (isTaken) break;
await new Promise(resolve => setTimeout(resolve, 100));
}
await tempSwarm.destroy();
return !isTaken;
}
async reconnect() {
if (!this.swarm) return;
console.log("[P2P] Network online event detected. Reconnecting...");
try {
await this.swarm.flush();
} catch (e) {
console.warn("[P2P] Reconnect flush failed:", e);
}
}
async initialize(seedHex, displayName, username, avatar = null) {
this.displayName = displayName;
this.username = (username || 'unknown').toLowerCase();
this.avatar = avatar;
// Run time sync in the background so it doesn't block UI boot
this._syncTimeWithServer().catch(() => {});
let instanceId = 'default';
if (typeof window !== 'undefined') {
instanceId = localStorage.getItem('pear_instance_id');
if (!instanceId) {
instanceId = generateUUID();
localStorage.setItem('pear_instance_id', instanceId);
}
const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
localDeleted.forEach(id => this.deletedMessages.add(id));
}
let basePath = './p2p-storage';
if (os && path && typeof os.homedir === 'function') {
const home = os.homedir();
const appData = process.platform === 'win32'
? process.env.APPDATA
: (process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : path.join(home, '.config'));
basePath = path.join(appData || home, 'Peercord', 'p2p-storage');
}
const hashBuf = b4a.alloc(32);
sodium.crypto_generichash(hashBuf, b4a.from(seedHex, 'hex'));
const accountHash = b4a.toString(hashBuf, 'hex').substring(0, 16);
this.storagePath = path.join(basePath, `${instanceId}-${accountHash}`);
if (fs && fs.existsSync) {
const badDownloadsPath = path.join(this.storagePath, 'downloads');
if (fs.existsSync(badDownloadsPath)) {
try { fs.rmSync(badDownloadsPath, { recursive: true, force: true }); } catch (e) {}
}
}
this.store = new Corestore(this.storagePath);
await this.store.ready();
const dbCore = this.store.get({ name: 'dm-db' }); await dbCore.ready();
this.db = new Hyperbee(dbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.db.ready();
const serverDbCore = this.store.get({ name: 'server-db' }); await serverDbCore.ready();
this.serverDb = new Hyperbee(serverDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.serverDb.ready();
const dirDbCore = this.store.get({ name: 'directory-db' }); await dirDbCore.ready();
this.dirDb = new Hyperbee(dirDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.dirDb.ready();
const pendingDbCore = this.store.get({ name: 'pending-requests-db' }); await pendingDbCore.ready();
this.pendingRequestsDb = new Hyperbee(pendingDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.pendingRequestsDb.ready();
const localFilesDbCore = this.store.get({ name: 'local-files-db' }); await localFilesDbCore.ready();
this.localFilesDb = new Hyperbee(localFilesDbCore, { keyEncoding: 'utf-8', valueEncoding: 'utf-8' }); await this.localFilesDb.ready();
const coresDbCore = this.store.get({ name: 'peer-cores-db' }); await coresDbCore.ready();
this.coresDb = new Hyperbee(coresDbCore, { keyEncoding: 'utf-8', valueEncoding: 'utf-8' }); await this.coresDb.ready();
const profilesDbCore = this.store.get({ name: 'profiles-db' }); await profilesDbCore.ready();
this.profilesDb = new Hyperbee(profilesDbCore, { keyEncoding: 'utf-8', valueEncoding: 'json' }); await this.profilesDb.ready();
for await (const { key, value } of this.db.createReadStream({ gt: 'dm:', lt: 'dm:~' })) { this.dms[key.split(':')[1]] = value; if (value.profile) this.knownProfiles.set(key.split(':')[1], value.profile); }
for await (const { key, value } of this.serverDb.createReadStream()) { this.servers.push({ topicHex: key, ...value }); }
for await (const { key, value } of this.dirDb.createReadStream()) { this.userDirectory.set(key, value); if (value.pubKey && value.profile) this.knownProfiles.set(value.pubKey, value.profile); }
for await (const { key } of this.pendingRequestsDb.createReadStream()) { this.pendingFriendRequests.add(key); }
for await (const { key, value } of this.profilesDb.createReadStream()) { this.knownProfiles.set(key, value); }
this.localCore = this.store.get({ name: 'user-messages', valueEncoding: 'json' }); await this.localCore.ready();
this.coreKey = b4a.toString(this.localCore.key, 'hex');
const seed = b4a.from(seedHex, 'hex');
const publicKey = b4a.alloc(32);
const secretKey = b4a.alloc(64);
sodium.crypto_sign_seed_keypair(publicKey, secretKey, seed);
this.myKey = b4a.toString(publicKey, 'hex');
this.secretKey = secretKey;
this.knownProfiles.set(this.myKey, { displayName: this.displayName, username: this.username, avatar: this.avatar });
// 1. INITIALIZE SWARM FIRST
this.swarm = new Hyperswarm({ keyPair: { publicKey, secretKey } });
this.swarm.on('connection', (conn, info) => {
this.store.replicate(conn);
const peerKey = b4a.toString(info.publicKey, 'hex');
this.peers.set(peerKey, { conn, displayName: 'Unknown', username: 'unknown', avatar: null, coreKey: null });
const identityMsg = JSON.stringify({ type: 'identity', displayName: this.displayName, username: this.username, avatar: this.avatar, coreKey: this.coreKey });
conn.write(b4a.from(identityMsg));
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
conn.on('data', async (data) => handleData(this, peerKey, data, conn));
conn.on('close', () => {
this.peers.delete(peerKey);
if (this.onPeerUpdate) this.onPeerUpdate(this.getPeerList());
});
});
// 2. JOIN ALL KNOWN TOPICS (Batched and Flushed to prevent NAT exhaustion)
const paceJoin = () => new Promise(resolve => setTimeout(resolve, 100));
let joinCount = 0;
if (this.username && this.username !== 'unknown') {
const myTopic = b4a.alloc(32);
sodium.crypto_generichash(myTopic, b4a.from('peercord-user:' + this.username));
this.swarm.join(myTopic, { client: false, server: true });
joinCount++;
}
for (const uname of this.pendingFriendRequests) {
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname));
this.swarm.join(topic, { client: true, server: false });
joinCount++;
if (joinCount % 5 === 0) {
try { await this.swarm.flush(); } catch(e) {}
} else {
await paceJoin();
}
}
// Join all server/group chat topics so we receive messages on boot!
for (const server of this.servers) {
await this._joinTopic(server.topicHex, true); // Skip flush inside the method
joinCount++;
// Batch flush every 5 topics to let the router's NAT table breathe
if (joinCount % 5 === 0) {
try { await this.swarm.flush(); } catch(e) {}
} else {
await paceJoin();
}
}
// Join global updates topic for instant OTA broadcasts
const globalUpdateTopic = b4a.alloc(32);
sodium.crypto_generichash(globalUpdateTopic, b4a.from('peercord-global-updates'));
this.swarm.join(globalUpdateTopic, { client: true, server: true });
// 3. PROCESS LOCAL MESSAGES
for (let i = 0; i < this.localCore.length; i++) { this.processMessage(await this.localCore.get(i)); }
// 4. EMIT INITIAL STATE IMMEDIATELY (Offline-First Boot)
this._emitKnownProfiles();
if (this.onInit) this.onInit(this.myKey);
if (this.onDMsUpdate) this.onDMsUpdate({ ...this.dms });
this._emitServers();
this._emitMessages();
// 5. LOAD PEER CORES
const corePromises =[];
for await (const { key, value } of this.coresDb.createReadStream()) {
corePromises.push(this.trackPeerCore(value));
}
await Promise.all(corePromises);
// 6. FLUSH SWARM (Final flush for any remaining un-flushed topics)
this.swarm.flush().then(() => {
console.log("[P2P] Swarm flushed and announced.");
}).catch(err => console.warn("[P2P] Swarm flush failed (offline?):", err));
}
getPeerList() {
return Array.from(this.peers.entries()).map(([key, info]) => ({
key, displayName: info.displayName, username: info.username, avatar: info.avatar
}));
}
async _joinTopic(topicHex, skipFlush = false) {
if (!this.swarm) return;
if (this.joinedTopics.has(topicHex)) return;
this.joinedTopics.add(topicHex);
const topic = b4a.from(topicHex, 'hex');
this.swarm.join(topic, { client: true, server: true });
if (!skipFlush) {
try { await this.swarm.flush(); } catch(e) {}
}
}
async close() {
if (this.swarm) {
for (const peer of this.swarm.connections) peer.destroy();
await this.swarm.destroy();
this.swarm = null;
}
if (this.db) { await this.db.close(); this.db = null; }
if (this.serverDb) { await this.serverDb.close(); this.serverDb = null; }
if (this.dirDb) { await this.dirDb.close(); this.dirDb = null; }
if (this.pendingRequestsDb) { await this.pendingRequestsDb.close(); this.pendingRequestsDb = null; }
if (this.localFilesDb) { await this.localFilesDb.close(); this.localFilesDb = null; }
if (this.coresDb) { await this.coresDb.close(); this.coresDb = null; }
if (this.profilesDb) { await this.profilesDb.close(); this.profilesDb = null; }
if (this.store) { await this.store.close(); this.store = null; }
this.peers.clear();
this.peerCores.clear();
this.knownProfiles.clear();
this.userDirectory.clear();
this.pendingWhois.clear();
this.pendingFriendRequests.clear();
this.messages.clear();
this.deletedMessages.clear();
this.dms = {};
this.servers =[];
this.serverMembers = {};
this.joinedTopics.clear();
this.transfers = {};
this.webrtcListeners.clear();
if (this._msgTimeout) clearTimeout(this._msgTimeout);
}
async wipeAllData() {
await this.close();
if (typeof window !== 'undefined') localStorage.removeItem('pear_discord_identity');
try {
if (this.storagePath && fs) await fs.promises.rm(this.storagePath, { recursive: true, force: true });
} catch (err) { console.error("Failed to delete storage directory:", err); }
window.location.reload();
}
async _getStorageStats() {
const stats = {
total: 0,
dms: {},
servers: {},
files:[]
};
for (const msg of this.messages.values()) {
if (msg.payload?.type === 'file' && msg.isMediaInDB) {
const size = msg.payload.file.size || 0;
stats.total += size;
const fileInfo = {
id: msg.payload.id,
name: msg.payload.file.name,
size: size,
coreKey: msg.payload.file.coreKey,
timestamp: msg.payload.timestamp,
channel: msg.channel,
recipient: msg.recipient
};
stats.files.push(fileInfo);
if (msg.recipient) {
const target = msg.sender === this.myKey ? msg.recipient : msg.sender;
stats.dms[target] = (stats.dms[target] || 0) + size;
} else {
const topicHex = msg.channel.substring(0, 64);
const channelName = msg.channel.substring(65);
if (!stats.servers[topicHex]) stats.servers[topicHex] = { total: 0, channels: {} };
stats.servers[topicHex].total += size;
stats.servers[topicHex].channels[channelName] = (stats.servers[topicHex].channels[channelName] || 0) + size;
}
}
}
stats.files.sort((a, b) => b.size - a.size);
return stats;
}
async _pruneFile(msgId) {
const msg = this.messages.get(msgId);
if (!msg) return;
try {
if (msg.localPath && fs && fs.existsSync(msg.localPath)) {
try { fs.unlinkSync(msg.localPath); } catch (e) { console.error("Failed to delete physical file:", e); }
}
if (msg.payload?.file?.coreKey) {
try {
const core = this.store.get({ key: b4a.from(msg.payload.file.coreKey, 'hex') });
await core.ready();
await core.clear(0, core.length);
} catch (e) { console.error("Failed to clear hypercore:", e); }
}
this.deletedMessages.add(msgId);
this.messages.delete(msgId);
if (typeof window !== 'undefined') {
const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
if (!localDeleted.includes(msgId)) {
localDeleted.push(msgId);
localStorage.setItem('pear_local_deleted_msgs', JSON.stringify(localDeleted));
}
}
if (this.transfers[msgId]) {
delete this.transfers[msgId];
if (this.onTransfersUpdate) this.onTransfersUpdate(this.transfers);
}
this._emitMessages();
} catch (err) {
console.error("Failed to prune file:", err);
}
}
}
export const network = new P2PNetwork();
export { initP2P, ADMIN_PUBLIC_KEY } from './utils.js';
export { generateIdentitySeed } from './modules/identity.js';

View File

@ -0,0 +1,88 @@
const b4a = window.require('b4a');
import { generateUUID, sodium } from '../utils.js';
export async function searchUser(network, targetUsername) {
const normalized = targetUsername.toLowerCase();
if (network.userDirectory.has(normalized)) {
return network.userDirectory.get(normalized);
}
// Join the DHT topic to reliably find the user even if they aren't in our current peer list
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
network.swarm.join(topic, { client: true, server: false });
return new Promise((resolve) => {
let resolved = false;
const finish = (result) => {
if (resolved) return;
resolved = true;
network.swarm.leave(topic);
resolve(result);
};
const timeout = setTimeout(() => {
finish(null);
}, 5000);
// Check periodically if they appeared in userDirectory after connecting
const interval = setInterval(() => {
if (network.userDirectory.has(normalized)) {
clearTimeout(timeout);
clearInterval(interval);
finish(network.userDirectory.get(normalized));
}
}, 500);
// Also broadcast whois to existing peers just in case
const queryId = generateUUID();
network.pendingWhois.set(queryId, (result) => {
clearTimeout(timeout);
clearInterval(interval);
finish(result);
});
const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized }));
for (const { conn } of network.peers.values()) {
conn.write(msg);
}
});
}
export async function queueFriendRequest(network, targetUsername) {
const uname = targetUsername.toLowerCase();
network.pendingFriendRequests.add(uname);
await network.pendingRequestsDb.put(uname, { timestamp: Date.now() });
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname));
network.swarm.join(topic, { client: true, server: false });
}
export async function trackPeerCore(network, coreKeyHex) {
if (network.peerCores.has(coreKeyHex)) return;
const core = network.store.get({ key: b4a.from(coreKeyHex, 'hex'), valueEncoding: 'json' });
await core.ready();
network.peerCores.set(coreKeyHex, core);
let processedSeq = -1;
// Process all existing messages
for (let i = 0; i < core.length; i++) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = i;
}
// Listen for new messages and process sequentially to prevent skipping rapid appends
core.on('append', async () => {
network._emitSync();
for (let i = processedSeq + 1; i < core.length; i++) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = i;
}
});
}

View File

@ -0,0 +1,271 @@
const b4a = window.require('b4a');
import { generateUUID, fs, path, os } from '../utils.js';
async function _hostFile(network, id, fileObj, fileCore) {
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
let processedBytes = 0;
let lastTime = Date.now();
let lastBytes = 0;
const updateProcessingProgress = (chunkLength) => {
processedBytes += chunkLength;
const now = Date.now();
if (now - lastTime >= 250 || processedBytes >= fileObj.size) {
const timeDiff = (now - lastTime) / 1000;
const speed = timeDiff > 0 ? (processedBytes - lastBytes) / timeDiff : 0;
const progress = Math.min(1, processedBytes / fileObj.size);
network.transfers[id] = { progress, speed, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
lastTime = now;
lastBytes = processedBytes;
}
};
// Append to local core (fast local disk I/O)
if (fileObj.path && fs) {
const stream = fs.createReadStream(fileObj.path, { highWaterMark: 64 * 1024 });
for await (const chunk of stream) {
await fileCore.append(chunk);
updateProcessingProgress(chunk.length);
}
} else if (fileObj.fileObj && typeof fileObj.fileObj.stream === 'function') {
const stream = fileObj.fileObj.stream();
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
await fileCore.append(b4a.from(value));
updateProcessingProgress(value.length);
}
} else if (fileObj.buffer) {
const buf = b4a.from(fileObj.buffer);
const chunkSize = 64 * 1024;
for(let i=0; i<buf.length; i+=chunkSize) {
const chunk = buf.subarray(i, i+chunkSize);
await fileCore.append(chunk);
updateProcessingProgress(chunk.length);
}
}
const msg = network.messages.get(id);
if (msg) {
if (fileObj.path) {
msg.localPath = fileObj.path;
msg.isMediaInDB = false; // Do not count sent files towards storage limits
await network.localFilesDb.put(id, fileObj.path); // Remember original path across restarts
} else if (fileObj.fileObj && typeof URL !== 'undefined') {
msg.localBlobUrl = URL.createObjectURL(fileObj.fileObj);
msg.isMediaInDB = false;
} else if (fileObj.buffer && typeof URL !== 'undefined') {
const blob = new Blob([fileObj.buffer], { type: fileObj.type });
msg.localBlobUrl = URL.createObjectURL(blob);
msg.isMediaInDB = false;
} else {
msg.isMediaInDB = false;
}
network._emitMessages();
}
// Set state to uploading, wait for receiver to send ephemeral progress messages
// Fix: Do not overwrite if the receiver already finished downloading it concurrently
const currentTransfer = network.transfers[id] || {};
if (currentTransfer.state !== 'completed') {
network.transfers[id] = {
progress: currentTransfer.progress || 0,
speed: currentTransfer.speed || 0,
state: 'uploading'
};
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
}
}
export async function sendFile(network, channel, text, fileObj) {
const id = generateUUID();
const fileCore = network.store.get({ name: 'file-' + id });
await fileCore.ready();
const coreKey = b4a.toString(fileCore.key, 'hex');
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
await network._appendSignedMessage({
type: 'file', id, channel, text,
file: { name: fileObj.name, size: fileObj.size, mimeType: fileObj.type, coreKey },
timestamp: Date.now()
});
await _hostFile(network, id, fileObj, fileCore);
}
export async function sendDMFile(network, targetKey, text, fileObj) {
const id = generateUUID();
const fileCore = network.store.get({ name: 'file-' + id });
await fileCore.ready();
const coreKey = b4a.toString(fileCore.key, 'hex');
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
await network._appendEncryptedMessage(targetKey, {
type: 'file', id, text,
file: { name: fileObj.name, size: fileObj.size, mimeType: fileObj.type, coreKey },
timestamp: Date.now()
});
await _hostFile(network, id, fileObj, fileCore);
}
export async function downloadFile(network, msgId, fileMeta, isSender) {
if (typeof window !== 'undefined') {
const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
if (localDeleted.includes(msgId)) return;
}
if (isSender) {
if (network.transfers[msgId] && network.transfers[msgId].state === 'processing') {
return; // Currently being hosted
}
}
// Check localFilesDb for BOTH sender and receiver to instantly restore paths on startup
const storedPath = await network.localFilesDb.get(msgId);
if (storedPath && storedPath.value && fs && fs.existsSync(storedPath.value)) {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = storedPath.value;
msg.isMediaInDB = fileMeta.mimeType?.startsWith('image/') || fileMeta.mimeType?.startsWith('video/');
network._emitMessages();
}
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
return;
}
const core = network.store.get({ key: b4a.from(fileMeta.coreKey, 'hex') });
await core.ready();
const isMedia = fileMeta.mimeType?.startsWith('image/') || fileMeta.mimeType?.startsWith('video/');
let downloadsDir;
let filePath;
if (isMedia) {
downloadsDir = path.join(network.storagePath, 'downloads');
if (!fs.existsSync(downloadsDir)) fs.mkdirSync(downloadsDir, { recursive: true });
const safeName = fileMeta.name.replace(/[^a-zA-Z0-9.-]/g, '_');
filePath = path.join(downloadsDir, `${msgId}-${safeName}`);
} else {
downloadsDir = path.join(os.homedir(), 'Downloads');
if (!fs.existsSync(downloadsDir)) fs.mkdirSync(downloadsDir, { recursive: true });
const safeName = fileMeta.name.replace(/[^a-zA-Z0-9.\-_ ]/g, '');
filePath = path.join(downloadsDir, safeName);
const existingMsg = network.messages.get(msgId);
if (existingMsg && existingMsg.localPath) {
filePath = existingMsg.localPath;
} else if (fs.existsSync(filePath)) {
// Collision handling: Rename if a file exists and isn't exactly our target size
const stats = fs.statSync(filePath);
if (stats.size !== fileMeta.size) {
let baseName = path.basename(safeName, path.extname(safeName));
let ext = path.extname(safeName);
let counter = 1;
while (fs.existsSync(filePath)) {
filePath = path.join(downloadsDir, `${baseName} (${counter})${ext}`);
counter++;
}
}
}
}
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
if (stats.size >= fileMeta.size) {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = filePath;
msg.isMediaInDB = isMedia;
network._emitMessages();
}
await network.localFilesDb.put(msgId, filePath);
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
return;
} else {
// Partial file exists, delete it to restart cleanly
try { fs.unlinkSync(filePath); } catch(e) {}
}
}
network.transfers[msgId] = { progress: 0, speed: 0, state: 'downloading' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
const readStream = core.createReadStream({ live: true });
const writeStream = fs.createWriteStream(filePath);
let downloadedBytes = 0;
let lastTime = Date.now();
let lastBytes = 0;
let isFinished = false;
const sendProgress = (progress, speed) => {
network.transfers[msgId] = { progress, speed, state: 'downloading' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
// Send ephemeral progress to the sender
network.sendEphemeral({ type: 'transfer_progress', id: msgId, progress, speed });
};
writeStream.on('finish', async () => {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = filePath;
msg.isMediaInDB = isMedia;
network._emitMessages();
}
await network.localFilesDb.put(msgId, filePath);
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
// Final progress sync
sendProgress(1, 0);
});
writeStream.on('error', (err) => {
console.error("File write error:", err);
});
if (fileMeta.size === 0) {
writeStream.end();
return;
}
// Manually pump the stream to avoid pipe race conditions
readStream.on('data', (chunk) => {
if (isFinished) return;
downloadedBytes += chunk.length;
writeStream.write(chunk);
const now = Date.now();
if (now - lastTime >= 500 || downloadedBytes >= fileMeta.size) {
const timeDiff = (now - lastTime) / 1000;
const speed = timeDiff > 0 ? (downloadedBytes - lastBytes) / timeDiff : 0;
const progress = Math.min(1, downloadedBytes / fileMeta.size);
sendProgress(progress, Math.max(0, speed));
lastTime = now;
lastBytes = downloadedBytes;
}
if (downloadedBytes >= fileMeta.size) {
isFinished = true;
readStream.destroy(); // Stop reading from hypercore
writeStream.end(); // Close file safely to trigger finish event
}
});
}

View File

@ -0,0 +1,65 @@
const b4a = window.require('b4a');
import { sodium } from '../utils.js';
export function generateIdentitySeed() {
const buffer = b4a.alloc(32);
sodium.randombytes_buf(buffer);
return b4a.toString(buffer, 'hex');
}
export function getSharedSecret(network, targetPubKeyHex) {
const myCurveSec = b4a.alloc(sodium.crypto_scalarmult_BYTES);
const theirCurvePub = b4a.alloc(sodium.crypto_scalarmult_BYTES);
const theirEdPub = b4a.from(targetPubKeyHex, 'hex');
sodium.crypto_sign_ed25519_sk_to_curve25519(myCurveSec, network.secretKey);
sodium.crypto_sign_ed25519_pk_to_curve25519(theirCurvePub, theirEdPub);
const sharedSecret = b4a.alloc(sodium.crypto_scalarmult_BYTES);
sodium.crypto_scalarmult(sharedSecret, myCurveSec, theirCurvePub);
return sharedSecret;
}
export function encryptPayload(payloadObj, sharedSecret) {
const message = b4a.from(JSON.stringify(payloadObj));
const nonce = b4a.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
sodium.randombytes_buf(nonce);
const cipher = b4a.alloc(message.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(cipher, message, null, null, nonce, sharedSecret);
return { nonce: b4a.toString(nonce, 'hex'), cipher: b4a.toString(cipher, 'hex') };
}
export function decryptPayload(nonceHex, cipherHex, sharedSecret) {
const nonce = b4a.from(nonceHex, 'hex');
const cipher = b4a.from(cipherHex, 'hex');
const message = b4a.alloc(cipher.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES);
try {
sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(message, null, cipher, null, nonce, sharedSecret);
return JSON.parse(b4a.toString(message));
} catch (e) {
return null;
}
}
export function updateProfile(network, displayName, avatar, username) {
network.displayName = displayName;
network.avatar = avatar;
if (username && username !== 'unknown' && network.username === 'unknown') {
network.username = username;
const myTopic = b4a.alloc(32);
sodium.crypto_generichash(myTopic, b4a.from('peercord-user:' + network.username));
network.swarm.join(myTopic, { client: false, server: true });
}
network.knownProfiles.set(network.myKey, { displayName, username: network.username, avatar });
if (network.profilesDb) network.profilesDb.put(network.myKey, { displayName, username: network.username, avatar });
network._emitKnownProfiles();
if (!network.swarm) return;
const identityMsg = JSON.stringify({ type: 'identity', displayName: network.displayName, username: network.username, avatar: network.avatar, coreKey: network.coreKey });
const payload = b4a.from(identityMsg);
for (const { conn } of network.peers.values()) conn.write(payload);
network._emitMessages();
}

View File

@ -0,0 +1,272 @@
const b4a = window.require('b4a');
import { generateUUID, sodium, ADMIN_PUBLIC_KEY } from '../utils.js';
import { getSharedSecret, encryptPayload, decryptPayload } from './identity.js';
export function getAllMessages(network) {
const joinedTopics = new Set(network.servers.map(s => s.topicHex));
return Array.from(network.messages.values()).filter(m => {
const ch = m.payload.channel;
if (ch && ch.length > 64 && ch[64] === '-') {
const topicHex = ch.substring(0, 64);
if (!joinedTopics.has(topicHex)) return false;
}
return true;
}).map(m => {
const known = network.knownProfiles.get(m.sender);
const isInvite = m.payload.type === 'server_invite';
const isFile = m.payload.type === 'file';
return {
id: m.payload.id,
channel: m.recipient ? m.recipient : m.payload.channel,
recipient: m.recipient,
text: isInvite ? null : m.payload.text,
payload: isInvite || isFile ? m.payload : null,
localPath: m.localPath,
localBlobUrl: m.localBlobUrl,
isMediaInDB: m.isMediaInDB,
timestamp: m.payload.timestamp,
logicalTime: m.payload.logicalTime || 0,
edited: m.payload.edited || false,
sender: m.sender,
senderName: known ? known.displayName : 'Unknown',
senderAvatar: known ? known.avatar : null,
// Pass the raw crypto data to the UI for verification
isEncrypted: !!m.cipher,
cipher: m.cipher || null,
nonce: m.nonce || null
};
}).sort((a, b) => {
if (a.logicalTime !== b.logicalTime) return a.logicalTime - b.logicalTime;
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
return a.id.localeCompare(b.id);
});
}
export async function processMessage(network, msg) {
if (!msg || !msg.sender) return;
if (msg.recipient) {
if (msg.recipient !== network.myKey && msg.sender !== network.myKey) return;
const targetKey = msg.sender === network.myKey ? msg.recipient : msg.sender;
const sharedSecret = getSharedSecret(network, targetKey);
const sigPayload = msg.nonce + msg.cipher + msg.recipient;
const isValid = sodium.crypto_sign_verify_detached(
b4a.from(msg.signature, 'hex'),
b4a.from(sigPayload),
b4a.from(msg.sender, 'hex')
);
if (!isValid) return;
const decrypted = decryptPayload(msg.nonce, msg.cipher, sharedSecret);
if (!decrypted) return;
msg.payload = decrypted;
if (decrypted.logicalTime) {
network.logicalClock = Math.max(network.logicalClock, decrypted.logicalTime) + 1;
}
if (decrypted.type === 'server_invite') {
if (!network.messages.has(decrypted.id)) {
network.messages.set(decrypted.id, msg);
network._emitMessages();
}
return;
}
if (decrypted.type === 'group_chat_add') {
const { topicHex, name, icon, owner, channels } = decrypted;
network.joinServer(topicHex, name, icon, owner, true, true, channels);
return;
}
if (msg.payload.type === 'dm_request' && msg.sender !== network.myKey) {
if (!network.dms[msg.sender]) {
network.dms[msg.sender] = { status: 'pending_incoming', profile: msg.payload.profile };
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
network.knownProfiles.set(msg.sender, msg.payload.profile);
network._emitKnownProfiles();
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
} else if (msg.payload.type === 'dm_accept' && msg.sender !== network.myKey) {
if (network.dms[msg.sender] && network.dms[msg.sender].status === 'pending_outgoing') {
network.dms[msg.sender].status = 'accepted';
await network.db.put('dm:' + msg.sender, network.dms[msg.sender]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
} else if (msg.payload.type === 'dm_chat' || msg.payload.type === 'file') {
if (!network.deletedMessages.has(msg.payload.id) && !network.messages.has(msg.payload.id)) {
network.messages.set(msg.payload.id, msg);
network._emitMessages();
if (msg.payload.type === 'file') {
network._downloadFile(msg.payload.id, msg.payload.file, msg.sender === network.myKey);
}
}
}
return;
}
if (!msg.signature || !msg.payloadStr) return;
try {
const sigBuf = b4a.from(msg.signature, 'hex');
const pubBuf = b4a.from(msg.sender, 'hex');
const isValid = sodium.crypto_sign_verify_detached(sigBuf, b4a.from(msg.payloadStr), pubBuf);
if (!isValid) return;
msg.payload = JSON.parse(msg.payloadStr);
if (msg.payload.logicalTime) {
network.logicalClock = Math.max(network.logicalClock, msg.payload.logicalTime) + 1;
}
} catch (err) { return; }
const { type, id, targetId, channel, text, serverTopicHex, allowAnyoneToInvite, name, icon, channels } = msg.payload;
if (type === 'server_delete') {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server && msg.sender === server.owner) {
await network._wipeLocalServerData(serverTopicHex);
}
return;
}
if (type === 'server_leave') {
if (network.serverMembers[serverTopicHex]) {
network.serverMembers[serverTopicHex].delete(msg.sender);
network._emitServerMembers();
}
return;
}
if (type === 'server_join') {
if (!network.serverMembers[serverTopicHex]) network.serverMembers[serverTopicHex] = new Set();
network.serverMembers[serverTopicHex].add(msg.sender);
network._emitServerMembers();
return;
}
if (type === 'server_settings_update') {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server && msg.sender === server.owner) {
if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
if (name !== undefined) server.name = name;
if (icon !== undefined) server.icon = icon;
if (channels !== undefined) server.channels = channels;
network.serverDb.put(serverTopicHex, server);
network._emitServers();
}
return;
}
if (type === 'delete') {
if (msg.sender === ADMIN_PUBLIC_KEY || msg.sender === network.messages.get(targetId)?.sender) {
network.deletedMessages.add(targetId);
network.messages.delete(targetId);
network._emitMessages();
}
return;
}
if (type === 'edit') {
const original = network.messages.get(targetId);
if (original && original.sender === msg.sender) {
original.payload.text = text;
original.payload.edited = true;
network._emitMessages();
}
return;
}
if (type === 'chat' || type === 'file') {
if (!network.deletedMessages.has(id) && !network.messages.has(id)) {
network.messages.set(id, msg);
network._emitMessages();
if (type === 'file') {
network._downloadFile(id, msg.payload.file, msg.sender === network.myKey);
}
}
}
}
export async function _appendSignedMessage(network, payloadObj) {
if (!network.localCore) return;
network.logicalClock++;
payloadObj.logicalTime = network.logicalClock;
payloadObj.timestamp = Date.now() + network.timeOffset;
payloadObj.senderName = network.displayName;
const payloadStr = JSON.stringify(payloadObj);
const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sigBuf, b4a.from(payloadStr), network.secretKey);
const finalMessage = {
sender: network.myKey,
senderName: network.displayName,
signature: b4a.toString(sigBuf, 'hex'),
payloadStr: payloadStr
};
await network.localCore.append(finalMessage);
processMessage(network, finalMessage);
}
export async function _appendEncryptedMessage(network, targetKey, payloadObj) {
if (!network.localCore) return;
network.logicalClock++;
payloadObj.logicalTime = network.logicalClock;
payloadObj.timestamp = Date.now() + network.timeOffset;
const sharedSecret = getSharedSecret(network, targetKey);
const { nonce, cipher } = encryptPayload(payloadObj, sharedSecret);
const sigPayload = nonce + cipher + targetKey;
const sigBuf = b4a.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sigBuf, b4a.from(sigPayload), network.secretKey);
const finalMessage = {
sender: network.myKey, recipient: targetKey, nonce, cipher, signature: b4a.toString(sigBuf, 'hex')
};
await network.localCore.append(finalMessage);
processMessage(network, finalMessage);
}
export async function sendDMRequest(network, targetKey, profile) {
network.dms[targetKey] = { status: 'pending_outgoing', profile };
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
await _appendEncryptedMessage(network, targetKey, { type: 'dm_request', profile: { displayName: network.displayName, username: network.username, avatar: network.avatar } });
}
export async function acceptDMRequest(network, targetKey) {
if (network.dms[targetKey]) {
network.dms[targetKey].status = 'accepted';
await network.db.put('dm:' + targetKey, network.dms[targetKey]);
if (network.onDMsUpdate) network.onDMsUpdate({ ...network.dms });
}
await _appendEncryptedMessage(network, targetKey, { type: 'dm_accept' });
}
export async function sendMessage(network, channel, text) { await _appendSignedMessage(network, { type: 'chat', id: generateUUID(), channel, text }); }
export async function sendDM(network, targetKey, text) { await _appendEncryptedMessage(network, targetKey, { type: 'dm_chat', id: generateUUID(), text }); }
export async function sendEditMessage(network, targetId, newText) { await _appendSignedMessage(network, { type: 'edit', id: generateUUID(), targetId, text: newText }); }
export async function sendDeleteMessage(network, targetId) { await _appendSignedMessage(network, { type: 'delete', id: generateUUID(), targetId }); }
export function sendEphemeral(network, payload) {
if (!network.swarm) return;
const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload }));
for (const { conn } of network.peers.values()) conn.write(msg);
}
export function sendOffline(network) { sendEphemeral(network, { type: 'offline' }); }
export function sendTyping(network, channel) { sendEphemeral(network, { type: 'typing', channel, displayName: network.displayName }); }
export function sendReadReceipt(network, channel, messageId = null) { sendEphemeral(network, { type: 'read', channel, messageId, timestamp: Date.now() }); }
export function sendDeliveredReceipt(network, channel, messageId = null) { sendEphemeral(network, { type: 'delivered', channel, messageId, timestamp: Date.now() }); }

View File

@ -0,0 +1,104 @@
const b4a = window.require('b4a');
import { generateUUID, sodium } from '../utils.js';
export async function createServer(network, name, icon, allowAnyoneToInvite, isGroupChat = false) {
const topic = b4a.alloc(32);
sodium.randombytes_buf(topic);
const topicHex = b4a.toString(topic, 'hex');
const channels = { text: ['general-chat'], voice: ['general-voice'] };
const serverInfo = { name, icon, owner: network.myKey, allowAnyoneToInvite, isGroupChat, channels };
network.servers.push({ topicHex, ...serverInfo });
network._emitServers();
await network.serverDb.put(topicHex, serverInfo);
await network._joinTopic(topicHex);
await network._appendSignedMessage({ type: 'server_join', serverTopicHex: topicHex, timestamp: Date.now() });
return { topicHex, ...serverInfo };
}
export async function joinServer(network, topicHex, name, icon, owner, allowAnyoneToInvite, isGroupChat = false, channels = null) {
if (network.servers.some(s => s.topicHex === topicHex)) return;
const serverInfo = { name, icon, owner, allowAnyoneToInvite, isGroupChat, channels: channels || { text: ['general-chat'], voice: ['general-voice'] } };
network.servers.push({ topicHex, ...serverInfo });
network._emitServers();
await network.serverDb.put(topicHex, serverInfo);
await network._joinTopic(topicHex);
await network._appendSignedMessage({ type: 'server_join', serverTopicHex: topicHex, timestamp: Date.now() });
await network._reloadCores();
}
export async function deleteServer(network, topicHex) {
await network._appendSignedMessage({ type: 'server_delete', serverTopicHex: topicHex, timestamp: Date.now() });
await network._wipeLocalServerData(topicHex);
}
export async function leaveServer(network, topicHex) {
await network._appendSignedMessage({ type: 'server_leave', serverTopicHex: topicHex, timestamp: Date.now() });
await network._wipeLocalServerData(topicHex);
}
export async function sendServerInvite(network, targetKey, serverTopicHex) {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (!server) return;
await network._appendEncryptedMessage(targetKey, {
id: generateUUID(),
type: 'server_invite',
timestamp: Date.now(),
inviterName: network.displayName,
serverName: server.name,
serverIcon: server.icon,
serverTopicHex: server.topicHex,
serverOwner: server.owner,
allowAnyoneToInvite: server.allowAnyoneToInvite,
isGroupChat: server.isGroupChat,
channels: server.channels
});
}
export async function sendGroupChatAdd(network, targetKey, serverTopicHex) {
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (!server) return;
await network._appendEncryptedMessage(targetKey, {
id: generateUUID(),
type: 'group_chat_add',
timestamp: Date.now(),
topicHex: server.topicHex,
name: server.name,
icon: server.icon,
owner: server.owner,
channels: server.channels
});
}
export async function updateServerSettings(network, serverTopicHex, name, icon, allowAnyoneToInvite, channels) {
await network._appendSignedMessage({
type: 'server_settings_update',
serverTopicHex,
name,
icon,
allowAnyoneToInvite,
channels,
timestamp: Date.now()
});
const server = network.servers.find(s => s.topicHex === serverTopicHex);
if (server) {
if (name !== undefined) server.name = name;
if (icon !== undefined) server.icon = icon;
if (allowAnyoneToInvite !== undefined) server.allowAnyoneToInvite = allowAnyoneToInvite;
if (channels !== undefined) server.channels = channels;
await network.serverDb.put(serverTopicHex, server);
network._emitServers();
}
}

View File

@ -0,0 +1,18 @@
const b4a = window.require('b4a');
export function addWebRTCListener(network, fn) {
network.webrtcListeners.add(fn);
}
export function removeWebRTCListener(network, fn) {
network.webrtcListeners.delete(fn);
}
export function sendWebRTCSignal(network, targetKey, payload) {
if (!network.swarm) return;
const peerInfo = network.peers.get(targetKey);
if (peerInfo && peerInfo.conn) {
const msg = b4a.from(JSON.stringify({ type: 'ephemeral', payload }));
peerInfo.conn.write(msg);
}
}

View File

@ -0,0 +1,393 @@
const b4a = window.require('b4a');
const crypto = window.require('crypto');
const Hyperswarm = window.require('hyperswarm');
export async function initNetwork() {
// Kept for legacy compatibility if imported elsewhere
}
class P2PNetwork {
constructor() {
this.swarm = null;
this.peers = new Set();
this.onPeerConnect = null;
this.onPeerDisconnect = null;
}
async initialize() {
try {
this.swarm = new Hyperswarm();
this.swarm.on('connection', (conn, info) => {
const peerKey = b4a.toString(info.publicKey, 'hex');
this.peers.add(peerKey);
if (this.onPeerConnect) this.onPeerConnect(peerKey);
conn.on('close', () => {
this.peers.delete(peerKey);
if (this.onPeerDisconnect) this.onPeerDisconnect(peerKey);
});
conn.on('data', (data) => {
console.log(`Received data from ${peerKey}:`, data.toString());
});
});
console.log('P2P Network Initialized');
} catch (err) {
console.error('Failed to initialize Hyperswarm.', err);
}
}
async joinGlobalServer() {
if (!this.swarm) return;
const globalTopicSeed = crypto.createHash('sha256').update('GLOBAL_MAIN_SERVER_V1').digest();
const discovery = this.swarm.join(globalTopicSeed, { client: true, server: true });
await discovery.flushed();
console.log('Joined Global Main Server Swarm');
}
}
export const networkLegacy = new P2PNetwork();
--- START OF FILE src/p2p/modules/discovery.js ---
const b4a = window.require('b4a');
import { generateUUID, sodium } from '../utils.js';
export async function searchUser(network, targetUsername) {
const normalized = targetUsername.toLowerCase();
if (network.userDirectory.has(normalized)) {
return network.userDirectory.get(normalized);
}
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + normalized));
network.swarm.join(topic, { client: true, server: false });
return new Promise((resolve) => {
let resolved = false;
const finish = (result) => {
if (resolved) return;
resolved = true;
network.swarm.leave(topic);
resolve(result);
};
const timeout = setTimeout(() => {
finish(null);
}, 5000);
const interval = setInterval(() => {
if (network.userDirectory.has(normalized)) {
clearTimeout(timeout);
clearInterval(interval);
finish(network.userDirectory.get(normalized));
}
}, 500);
const queryId = generateUUID();
network.pendingWhois.set(queryId, (result) => {
clearTimeout(timeout);
clearInterval(interval);
finish(result);
});
const msg = b4a.from(JSON.stringify({ type: 'whois', queryId, username: normalized }));
for (const { conn } of network.peers.values()) {
conn.write(msg);
}
});
}
export async function queueFriendRequest(network, targetUsername) {
const uname = targetUsername.toLowerCase();
network.pendingFriendRequests.add(uname);
await network.pendingRequestsDb.put(uname, { timestamp: Date.now() });
const topic = b4a.alloc(32);
sodium.crypto_generichash(topic, b4a.from('peercord-user:' + uname));
network.swarm.join(topic, { client: true, server: false });
}
export async function trackPeerCore(network, coreKeyHex) {
if (network.peerCores.has(coreKeyHex)) return;
const core = network.store.get({ key: b4a.from(coreKeyHex, 'hex'), valueEncoding: 'json' });
await core.ready();
network.peerCores.set(coreKeyHex, core);
let processedSeq = -1;
for (let i = 0; i < core.length; i++) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = i;
}
core.on('append', async () => {
network._emitSync();
for (let i = processedSeq + 1; i < core.length; i++) {
const msg = await core.get(i);
network.processMessage(msg);
processedSeq = i;
}
});
}
--- START OF FILE src/p2p/modules/files.js ---
const b4a = window.require('b4a');
import { generateUUID, fs, path, os } from '../utils.js';
async function _hostFile(network, id, fileObj, fileCore) {
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
let processedBytes = 0;
let lastTime = Date.now();
let lastBytes = 0;
const updateProcessingProgress = (chunkLength) => {
processedBytes += chunkLength;
const now = Date.now();
if (now - lastTime >= 250 || processedBytes >= fileObj.size) {
const timeDiff = (now - lastTime) / 1000;
const speed = timeDiff > 0 ? (processedBytes - lastBytes) / timeDiff : 0;
const progress = Math.min(1, processedBytes / fileObj.size);
network.transfers[id] = { progress, speed, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
lastTime = now;
lastBytes = processedBytes;
}
};
if (fileObj.path && fs) {
const stream = fs.createReadStream(fileObj.path, { highWaterMark: 64 * 1024 });
for await (const chunk of stream) {
await fileCore.append(chunk);
updateProcessingProgress(chunk.length);
}
} else if (fileObj.fileObj && typeof fileObj.fileObj.stream === 'function') {
const stream = fileObj.fileObj.stream();
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
await fileCore.append(b4a.from(value));
updateProcessingProgress(value.length);
}
} else if (fileObj.buffer) {
const buf = b4a.from(fileObj.buffer);
const chunkSize = 64 * 1024;
for(let i=0; i<buf.length; i+=chunkSize) {
const chunk = buf.subarray(i, i+chunkSize);
await fileCore.append(chunk);
updateProcessingProgress(chunk.length);
}
}
const msg = network.messages.get(id);
if (msg) {
if (fileObj.path) {
msg.localPath = fileObj.path;
msg.isMediaInDB = false;
await network.localFilesDb.put(id, fileObj.path);
} else if (fileObj.fileObj && typeof URL !== 'undefined') {
msg.localBlobUrl = URL.createObjectURL(fileObj.fileObj);
msg.isMediaInDB = false;
} else if (fileObj.buffer && typeof URL !== 'undefined') {
const blob = new Blob([fileObj.buffer], { type: fileObj.type });
msg.localBlobUrl = URL.createObjectURL(blob);
msg.isMediaInDB = false;
} else {
msg.isMediaInDB = false;
}
network._emitMessages();
}
network.transfers[id] = { progress: 0, speed: 0, state: 'uploading' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
}
export async function sendFile(network, channel, text, fileObj) {
const id = generateUUID();
const fileCore = network.store.get({ name: 'file-' + id });
await fileCore.ready();
const coreKey = b4a.toString(fileCore.key, 'hex');
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
await network._appendSignedMessage({
type: 'file', id, channel, text,
file: { name: fileObj.name, size: fileObj.size, mimeType: fileObj.type, coreKey },
timestamp: Date.now()
});
await _hostFile(network, id, fileObj, fileCore);
}
export async function sendDMFile(network, targetKey, text, fileObj) {
const id = generateUUID();
const fileCore = network.store.get({ name: 'file-' + id });
await fileCore.ready();
const coreKey = b4a.toString(fileCore.key, 'hex');
network.transfers[id] = { progress: 0, speed: 0, state: 'processing' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
await network._appendEncryptedMessage(targetKey, {
type: 'file', id, text,
file: { name: fileObj.name, size: fileObj.size, mimeType: fileObj.type, coreKey },
timestamp: Date.now()
});
await _hostFile(network, id, fileObj, fileCore);
}
export async function downloadFile(network, msgId, fileMeta, isSender) {
if (typeof window !== 'undefined') {
const localDeleted = JSON.parse(localStorage.getItem('pear_local_deleted_msgs') || '[]');
if (localDeleted.includes(msgId)) return;
}
if (isSender) {
if (network.transfers[msgId] && network.transfers[msgId].state === 'processing') {
return;
}
}
const storedPath = await network.localFilesDb.get(msgId);
if (storedPath && storedPath.value && fs && fs.existsSync(storedPath.value)) {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = storedPath.value;
msg.isMediaInDB = fileMeta.mimeType?.startsWith('image/') || fileMeta.mimeType?.startsWith('video/');
network._emitMessages();
}
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
return;
}
const core = network.store.get({ key: b4a.from(fileMeta.coreKey, 'hex') });
await core.ready();
const isMedia = fileMeta.mimeType?.startsWith('image/') || fileMeta.mimeType?.startsWith('video/');
let downloadsDir;
let filePath;
if (isMedia) {
downloadsDir = path.join(network.storagePath, 'downloads');
if (!fs.existsSync(downloadsDir)) fs.mkdirSync(downloadsDir, { recursive: true });
const safeName = fileMeta.name.replace(/[^a-zA-Z0-9.-]/g, '_');
filePath = path.join(downloadsDir, `${msgId}-${safeName}`);
} else {
downloadsDir = path.join(os.homedir(), 'Downloads');
if (!fs.existsSync(downloadsDir)) fs.mkdirSync(downloadsDir, { recursive: true });
const safeName = fileMeta.name.replace(/[^a-zA-Z0-9.\-_ ]/g, '');
filePath = path.join(downloadsDir, safeName);
const existingMsg = network.messages.get(msgId);
if (existingMsg && existingMsg.localPath) {
filePath = existingMsg.localPath;
} else if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
if (stats.size !== fileMeta.size) {
let baseName = path.basename(safeName, path.extname(safeName));
let ext = path.extname(safeName);
let counter = 1;
while (fs.existsSync(filePath)) {
filePath = path.join(downloadsDir, `${baseName} (${counter})${ext}`);
counter++;
}
}
}
}
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
if (stats.size >= fileMeta.size) {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = filePath;
msg.isMediaInDB = isMedia;
network._emitMessages();
}
await network.localFilesDb.put(msgId, filePath);
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
return;
} else {
try { fs.unlinkSync(filePath); } catch(e) {}
}
}
network.transfers[msgId] = { progress: 0, speed: 0, state: 'downloading' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
const readStream = core.createReadStream({ live: true });
const writeStream = fs.createWriteStream(filePath);
let downloadedBytes = 0;
let lastTime = Date.now();
let lastBytes = 0;
let isFinished = false;
const sendProgress = (progress, speed) => {
network.transfers[msgId] = { progress, speed, state: 'downloading' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
network.sendEphemeral({ type: 'transfer_progress', id: msgId, progress, speed });
};
writeStream.on('finish', async () => {
const msg = network.messages.get(msgId);
if (msg) {
msg.localPath = filePath;
msg.isMediaInDB = isMedia;
network._emitMessages();
}
await network.localFilesDb.put(msgId, filePath);
network.transfers[msgId] = { progress: 1, speed: 0, state: 'completed' };
if (network.onTransfersUpdate) network.onTransfersUpdate(network.transfers);
sendProgress(1, 0);
});
writeStream.on('error', (err) => {
console.error("File write error:", err);
});
if (fileMeta.size === 0) {
writeStream.end();
return;
}
readStream.on('data', (chunk) => {
if (isFinished) return;
downloadedBytes += chunk.length;
writeStream.write(chunk);
const now = Date.now();
if (now - lastTime >= 500 || downloadedBytes >= fileMeta.size) {
const timeDiff = (now - lastTime) / 1000;
const speed = timeDiff > 0 ? (downloadedBytes - lastBytes) / timeDiff : 0;
const progress = Math.min(1, downloadedBytes / fileMeta.size);
sendProgress(progress, Math.max(0, speed));
lastTime = now;
lastBytes = downloadedBytes;
}
if (downloadedBytes >= fileMeta.size) {
isFinished = true;
readStream.destroy();
writeStream.end();
}
});
}

View File

@ -0,0 +1,25 @@
const b4a = window.require('b4a');
export let Hyperswarm, Corestore, Hyperbee, sodium, fs, os, path, http;
// The PUBLIC key is 100% safe to be in the open-source code.
// It is mathematically impossible to derive your private seed from it.
export const ADMIN_PUBLIC_KEY = '[PLACE_HOLDER]';
export async function initP2P() {
const req = window.require;
Hyperswarm = req('hyperswarm');
Corestore = req('corestore');
Hyperbee = req('hyperbee');
sodium = req('sodium-native');
fs = req('fs');
os = req('os');
path = req('path');
http = req('http');
}
export function generateUUID() {
const buffer = b4a.alloc(16);
sodium.randombytes_buf(buffer);
return b4a.toString(buffer, 'hex');
}

View File

@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content:[
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
base: 'var(--color-base)',
surface: 'var(--color-surface)',
panel: 'var(--color-panel)',
accent: 'var(--color-accent)',
text: 'var(--color-text)',
muted: 'var(--color-muted)'
}
},
},
plugins:[
require('@tailwindcss/typography'),
],
}

View File

@ -0,0 +1,46 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
optimizeDeps: {
exclude:[
'hyperswarm',
'b4a',
'sodium-native',
'corestore',
'hypercore',
'autobase',
'hyperbee',
'pear-runtime',
'os',
'http',
'child_process'
]
},
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
external:[
'hyperswarm',
'b4a',
'sodium-native',
'corestore',
'hypercore',
'autobase',
'hyperbee',
'pear-runtime',
'events',
'fs',
'path',
'crypto',
'stream',
'os',
'http',
'child_process'
]
}
}
})

View File

@ -122,12 +122,14 @@ Peercord uses a highly secure, decentralized update system. Updates are seeded v
To prevent malicious actors from broadcasting fake updates, the system uses **Ed25519 cryptographic signatures**.
### Setting up your own keys (For Forks/Developers)
If you fork this repository, you **must** generate your own cryptographic keys to broadcast updates. The codebase currently contains placeholders.
### Setting up your own keys & Pear Links (For Forks/Developers)
If you fork this repository, you **must** generate your own cryptographic keys to broadcast updates and your own Pear link for the OTA updater. The codebase currently contains placeholders.
1. **Generate Keys**: Run `node scripts/genkeys.js` locally to generate an Ed25519 keypair.
2. **Public Key**: Place your generated Public Key in `src/p2p/utils.js` (`ADMIN_PUBLIC_KEY`). This is safe to be public and is used by clients to verify the update came from you.
3. **Private Key (Seed)**: Place your generated Private Seed in `scripts/broadcast-update.js` (`ADMIN_SEED_HEX`). **DO NOT COMMIT THIS FILE TO VERSION CONTROL.** Keep it strictly local.
4. **Generate Pear Link**: Run `pear touch` in your terminal to generate a new Pear link.
5. **Update package.json**: Replace all instances of the existing `pear://...` link (or `[PEAR_LINK]` placeholders) in `package.json` (specifically in the `upgrade` field and the `pear:stage`/`pear:seed` scripts) with your newly generated Pear link.
### Broadcasting an Update
When you are ready to release a new version:
@ -151,3 +153,40 @@ npm install
# Start the Vite development server and Electron wrapper
npm run start
```
### Building the App
```bash
# Build the React UI
npm run build:ui
# Package for Windows
npm run package:win
# Package for Linux
npm run package:linux
```
### Building the Installer
```bash
# Navigate to the installer directory (assuming it's in the root)
# dotnet build -c Release
```
---
## 🤝 Contributing
Contributions are welcome! Because this is a P2P application, please ensure that any changes to the database schemas (`Hyperbee`) or message payloads (`Hypercore`) are backwards compatible, or include migration logic, to prevent breaking the network for older clients.
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
---
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.