diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml deleted file mode 100644 index dc1e9c7..0000000 --- a/.github/workflows/android.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: "Build APK" - -on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: - -jobs: - build-apk: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - - name: Decode keystore - run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks - env: - KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} - - - name: Build app - run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --release" - env: - KEYSTORE_PATH: ../../keystore.jks - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: APK - path: build/app/outputs/flutter-apk/app-release.apk \ No newline at end of file diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml deleted file mode 100644 index 5e693f0..0000000 --- a/.github/workflows/flatpak.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: "Build Flatpaks" - -on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: - -jobs: - build-flatpak: - strategy: - fail-fast: false - matrix: - include: - - arch: x86_64 - runner: ubuntu-latest - - arch: aarch64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - - name: Build app - run: nix build .#flatpak - - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: flatpak-${{ matrix.arch }} - path: result/nexus.federated.Nexus.flatpak \ No newline at end of file diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml deleted file mode 100644 index c07f0ad..0000000 --- a/.github/workflows/windows.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: "Build EXE" - -on: - push: - branches: ["main"] - tags: ["*"] - workflow_dispatch: - -jobs: - build-exe: - runs-on: windows-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: 3.41.5 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: gomuks/go.mod - - - name: Go build - run: | - cd gomuks/pkg/ffi - go build -tags goolm -o ../../../libgomuks.dll -buildmode=c-shared - - - name: Build with Flutter - run: | - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build - flutter build windows --release - - - name: Upload exe zip - uses: actions/upload-artifact@v6 - with: - name: windows-portable - path: build/windows/x64/runner/Release/ - - - name: Install Inno Setup - run: choco install innosetup -y - - - name: Build Inno Setup installer - run: iscc windows/installer.iss - - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: windows-installer - path: windows/dist/Nexus-Setup.exe \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2bec583..dfaf03d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,4 @@ key.properties *.freezed.dart # Devel Password -password.txt - -# Nix -/result \ No newline at end of file +password.txt \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 145276a..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "gomuks"] - path = gomuks - url = https://github.com/gomuks/gomuks - branch = main diff --git a/.vscode/settings.json b/.vscode/settings.json index da80f4b..30f4254 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,3 @@ { - "cSpell.words": [ - "Appbar", - "Displayname", - "fluttertagger", - "Gomuks", - "Homeserver", - "localpart", - "muks", - "prefs" - ] + "cSpell.words": ["Appbar", "Displayname", "prefs"] } diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 496acdb..0000000 --- a/LICENSE +++ /dev/null @@ -1,675 +0,0 @@ -# GNU GENERAL PUBLIC LICENSE - -Version 3, 29 June 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. - - -Everyone is permitted to copy and distribute verbatim copies of this -license document, but changing it is not allowed. - -## Preamble - -The GNU 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. - - - Copyright (C) - - 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 . - -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: - - Copyright (C) - 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 . - -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 . diff --git a/README.md b/README.md index 0fe2a1b..670e20e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Nexus Client > [!WARNING] -> Nexus Client is still in development, and doesn't support everything needed for daily use. +> Nexus Client is still heavily in development, and is not ready for use! ## Description -A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. +A simple and user-friendly Matrix client made with Flutter and the Matrix Dart SDK. ## Screenshots @@ -15,173 +15,98 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [ ] New logo -- [ ] Make context menus appear as bottom sheets on mobile -- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 - - [ ] Allow using remote Gomuks over websocket -- [ ] Platform Support - - [x] Linux - - [ ] Windows (WIP) - - [ ] MacOS - - [x] Android - - [ ] iOS - - [ ] Web (may not be possible) -- [x] Login - - [x] Username / password auth - - [ ] OAuth / OIDC - - [x] Improve initial sync experience -- [x] Rooms / Spaces - - [x] Displaying and choosing - - [x] Reading, showing unread - - [x] Mark as read button on rooms and spaces - - [ ] Searching - - [ ] Creating (Rooms, Spaces, and DMs) - - [x] Joining - - [x] Parse vias - - [x] Using a text/uri/link - - [x] Plain text - - [x] `matrix:` Uri - - [x] Matrix.to link - - [ ] From space - - [ ] Exploring - - [x] Leaving - - [x] Subspaces -- [x] Messages - - [x] Encryption - - [x] Restoring crypto identity from a recovery passphrase/key - - [x] Sending - - [x] Plain text - - [x] HTML/Markdown - - [x] Replies - - [x] Choose ping on/off - - [ ] Per message profiles - - [ ] Attachments - - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - - [ ] Custom emojis/stickers - - [ ] GIFs using Gomuks' GIF proxies - - [x] Receiving - - [x] Plain text - - [x] Per message profiles - - [x] HTML - - [x] URL Previews - - [x] Replies - - [x] Viewing - - [ ] Jump to original message - - [x] In loaded timeline - - [ ] Out of loaded timeline - - [x] Edits - - [x] Attachments - - [x] Unencrypted - - [ ] Encrypted - - [x] Blurhashing - - [ ] Downloading attachments - - [x] Opening attachments in their own view - - [ ] Polls - - [x] Mentions - - [x] Users - - [x] Clickable - - [x] Rooms - - [x] Clickable - - [x] Matrix URIs - - [x] Matrix.to links - - [x] Events - - [ ] Render more nicely - - [ ] Clickable - - [x] Custom emojis/stickers - - [x] History loading - - [x] Backwards - - [ ] Forwards - - [x] Editing - - [x] Deleting -- [x] Reactions -- [ ] Pins - - [ ] Displaying - - [ ] Creating -- [ ] Threads -- [x] Profile popouts - - [x] Working actions -- [x] Copy link to: - - [x] Room - - [x] Space - - [x] Message -- [ ] Reporting - - [x] Events - - [ ] Rooms -- [ ] Notifications using UnifiedPush -- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) -- [ ] Invites -- [ ] Settings - - [ ] Matrix: URIs vs Matrix.to links - - [ ] Light/Dark mode - - [ ] SSD or CSD - - [ ] Show media by default - - [ ] Dynamic Theming - - [ ] Devices - - [ ] Viewing devices - - [ ] Verifying devices - - [ ] URL preview: Server / Sending Client (Beeper spec) / None - - [ ] Account changes - - [ ] Display name - - [ ] Profile picture - - [ ] Timezone - - [ ] Pronouns - - [ ] Password - - [ ] About - - [x] Log Out +- [ ] Platform Support + - [x] Linux + - [x] Windows (untested, if you are interested in helping to test, open an issue) + - [ ] MacOS + - [ ] Android + - [ ] iOS + - [ ] Web (may not be possible) +- [x] Login + - [x] Username / password auth + - [ ] OAuth / OIDC +- [x] Rooms / Spaces + - [x] Displaying and choosing + - [x] Reading, showing unread + - [ ] Mark as read button on rooms and spaces + - [ ] Searching + - [ ] Creating (Rooms, Spaces, and DMs) + - [ ] Joining + - [ ] Using alias + - [ ] From space + - [ ] Exploring + - [x] Leaving + - [x] Subspaces +- [x] Messages + - [x] Sending + - [x] Plain text + - [x] HTML/Markdown + - [x] Replies + - [ ] Attachments + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Custom emojis/stickers + - [ ] GIFs, maybe through Tenor or something + - [ ] Encrypted messages + - [x] Recieving + - [x] Plain text + - [x] HTML + - [x] Replies + - [x] Viewing + - [ ] Jump to original message + - [x] Edits + - [x] Attachments + - [ ] Downloading attachments + - [ ] Opening attachments in their own view + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Plain text + - [x] Matrix URIs + - [x] Matrix.to links + - [x] Custom emojis/stickers + - [ ] Encrypted messages + - [x] History loading + - [x] Backwards + - [ ] Forwards + - [ ] Editing + - [x] Deleting +- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 +- [ ] Pins + - [ ] Displaying + - [ ] Creating +- [ ] Threads +- [ ] Profile popouts +- [x] Copy link to [room, space] +- [x] Reporting +- [ ] Notifications using UnifiedPush +- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) +- [ ] Invites + - [ ] Viewing / accepting + - [ ] Spam filtering +- [ ] Settings + - [ ] Light/Dark mode + - [ ] Dynamic Theming + - [ ] Devices + - [ ] Viewing devices + - [ ] Verifying devices + - [ ] URL preview: Server / Client / None + - [ ] Account changes + - [ ] Display name + - [ ] Profile picture + - [ ] Timezone + - [ ] Pronouns + - [ ] Password + - [ ] About + - [x] Log Out -## Try it out +## Development -If you want to try out Nexus, grab one of the following artifacts from CI: +Fork and clone the project, then: -- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) -- Windows - - [Portable Build](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-portable.zip) - - [Installer](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) -- Flatpak - - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) - -Or, try the Nix package: `nix run git+https://git.federated.nexus/Henry-Hiles/nexus` - -## Build it yourself - -### Prerequisites - -#### Linux - -- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` -- Without Nix: Install Flutter, Go, Git, Libclang, and Glibc. Do not use any Snap packages, they cause various compilation issues. - -#### Windows / MacOS - -I don't really know. You will need Flutter, Git, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM. - -### Clone repo - -First, clone and open the repo: - -```sh -git clone --recurse-submodules https://git.federated.nexus/Henry-Hiles/nexus -cd nexus -``` - -### Set up Flutter - -Get dependencies: - -```sh -flutter pub get -``` - -Generate Gomuks bindings: - -```sh -dart scripts/generate.dart -``` +- With Nix: Either use direnv, or `nix flake develop` +- Without Nix: Install Flutter, Rust, the libsecret dev package for your distro (must be in `PKG_CONFIG_PATH`), and sqlite (must be in `LD_LIBRARY_PATH`). Build generated files, and watch for new changes: @@ -189,12 +114,8 @@ Build generated files, and watch for new changes: flutter pub run build_runner watch --delete-conflicting-outputs ``` -Run the app: - -```sh -flutter run -``` +Run `flutter run` to run the app. ## Community -Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. +Come chat in the [Federated Nexus Community](https://matrix.to/#/#space:federated.nexus) for questions or help with developing or using Nexus Client. diff --git a/analysis_options.yaml b/analysis_options.yaml index a8b1078..c2aaaa0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,6 @@ analyzer: errors: invalid_annotation_target: ignore - avoid_print: ignore exclude: - "build/**" - "**/*.g.dart" diff --git a/android/app/build.gradle b/android/app/build.gradle index fd51ea0..ce5f465 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -39,11 +39,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - // do we want to update.. eventually? - jvmTarget = "17" - } - defaultConfig { applicationId = "nexus.federated.Nexus" minSdk = 29 @@ -55,8 +50,7 @@ android { signingConfigs { release { keyAlias "key" - def storePath = keystoreProperties['path'] ?: System.getenv("KEYSTORE_PATH") - storeFile storePath ? file(storePath) : null + storeFile keystoreProperties['path'] ? file(keystoreProperties['path']) : file(System.getenv("KEYSTORE_PATH")) keyPassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD") storePassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 666977e..1c369c9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:label="Nexus" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" - android:roundIcon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/nexus_round" android:allowBackup="false" android:fullBackupContent="false"> - + - - - diff --git a/assets/foreground.png b/assets/foreground.png index 76a446a..4249989 100644 Binary files a/assets/foreground.png and b/assets/foreground.png differ diff --git a/assets/reactions.png b/assets/reactions.png deleted file mode 100644 index c413051..0000000 Binary files a/assets/reactions.png and /dev/null differ diff --git a/assets/screenshotDark.png b/assets/screenshotDark.png index ae75dc7..dec983f 100644 Binary files a/assets/screenshotDark.png and b/assets/screenshotDark.png differ diff --git a/assets/screenshotLight.png b/assets/screenshotLight.png index 0b2ce0d..003f6b4 100644 Binary files a/assets/screenshotLight.png and b/assets/screenshotLight.png differ diff --git a/assets/smallerForeground.png b/assets/smallerForeground.png deleted file mode 100644 index c962d2b..0000000 Binary files a/assets/smallerForeground.png and /dev/null differ diff --git a/assets/smallerForeground.svg b/assets/smallerForeground.svg deleted file mode 100644 index a821be9..0000000 --- a/assets/smallerForeground.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - - diff --git a/build.yaml b/build.yaml deleted file mode 100644 index 5d6aeda..0000000 --- a/build.yaml +++ /dev/null @@ -1,6 +0,0 @@ -targets: - $default: - builders: - json_serializable: - options: - field_rename: snake diff --git a/flake.lock b/flake.lock index 8070c6c..b627cd3 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1767609335, - "narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=", + "lastModified": 1759362264, + "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "250481aafeb741edfe23d29195671c19b36b6dca", + "rev": "758cf7296bee11f1706a574c77d072b8a7baa881", "type": "github" }, "original": { @@ -18,54 +18,17 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nix2flatpak": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1774604963, - "narHash": "sha256-MtAW1FIdirSlUAAO7s1u9auv5y3I6t3uJ+GeEbqiqxI=", - "owner": "neobrain", - "repo": "nix2flatpak", - "rev": "3e04657fbcb49956ac301410b071a7f0b2ad5988", - "type": "github" - }, - "original": { - "owner": "neobrain", - "repo": "nix2flatpak", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1773389992, - "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=", - "owner": "NixOS", + "lastModified": 1759381078, + "narHash": "sha256-gTrEEp5gEspIcCOx9PD8kMaF1iEmfBcTbO0Jag2QhQs=", + "owner": "nixos", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "7df7ff7d8e00218376575f0acdcc5d66741351ee", "type": "github" }, "original": { - "owner": "NixOS", + "owner": "nixos", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" @@ -73,11 +36,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1765674936, - "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", + "lastModified": 1754788789, + "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", + "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", "type": "github" }, "original": { @@ -86,42 +49,10 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1767640445, - "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", - "nix2flatpak": "nix2flatpak", - "nixpkgs": "nixpkgs_2" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 4c06ac6..027db59 100644 --- a/flake.nix +++ b/flake.nix @@ -2,10 +2,8 @@ description = "Nexus Flutter Flake"; inputs = { - self.submodules = true; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; - nix2flatpak.url = "github:neobrain/nix2flatpak"; }; outputs = @@ -25,7 +23,6 @@ perSystem = { - lib, pkgs, system, ... @@ -40,37 +37,35 @@ }; }; - packages = + devShells.default = let - default = pkgs.callPackage ./linux/nix/pkg { - src = self; - }; + # android = pkgs.callPackage ./nix/android.nix { }; in - { - inherit default; + pkgs.mkShell { + packages = with pkgs; [ + # jdk17 + cargo + (flutter.override { extraPkgConfigPackages = [ pkgs.libsecret ]; }) - flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak { - appName = "Nexus"; - developer = "QuadRadical"; - appId = "nexus.federated.Nexus"; - package = default; - runtime = "org.gnome.Platform/49"; - permissions = { - share = [ "network" ]; - sockets = [ - "fallback-x11" - "wayland" - ]; - devices = [ "dri" ]; - }; - }; + # android.platform-tools + (pkgs.writeShellScriptBin "rustup" (builtins.readFile ./nix/fake-rustup.sh)) + ]; - gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix { - src = self; + env = rec { + LD_LIBRARY_PATH = "${ + pkgs.lib.makeLibraryPath ([ + pkgs.sqlite + ]) + }:./build/linux/x64/debug/plugins/flutter_vodozemac"; + + # ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; + # ANDROID_SDK_ROOT = ANDROID_HOME; + # JAVA_HOME = pkgs.jdk17; + + # TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}"; + # GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2"; }; }; - - devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { }; }; }; } diff --git a/gomuks b/gomuks deleted file mode 160000 index daa0ba0..0000000 --- a/gomuks +++ /dev/null @@ -1 +0,0 @@ -Subproject commit daa0ba028e7d89ba9fc7580fc8099348e6145cb3 diff --git a/hook/build.dart b/hook/build.dart deleted file mode 100644 index 165e613..0000000 --- a/hook/build.dart +++ /dev/null @@ -1,129 +0,0 @@ -import "dart:io"; -import "package:hooks/hooks.dart"; -import "package:code_assets/code_assets.dart"; - -Future main(List args) => build(args, (input, output) async { - final codeConfig = input.config.code; - final targetOS = codeConfig.targetOS; - final targetArch = codeConfig.targetArchitecture; - - String libFileName; - Map env = {}; - switch (targetOS) { - case OS.linux: - libFileName = "libgomuks.so"; - break; - case OS.macOS: - libFileName = "libgomuks.dylib"; - break; - case OS.windows: - libFileName = "libgomuks.dll"; - break; - case OS.android: - libFileName = "libgomuks.so"; - - final targetNdkApi = codeConfig.android.targetNdkApi; - - final ndkHome = - Platform.environment["ANDROID_NDK_HOME"] ?? - Platform.environment["ANDROID_NDK_ROOT"] ?? - Platform.environment["NDK_HOME"] ?? - await _findNdkFromSdk(); - if (ndkHome == null) { - throw Exception( - "Could not find Android NDK. Set ANDROID_NDK_HOME or install via sdkmanager.", - ); - } - - final hostTag = _ndkHostTag(); - final (goArch, ccTriple) = _androidArch(targetArch); - final cc = - "$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang"; - - env = {"CGO_ENABLED": "1", "GOOS": "android", "GOARCH": goArch, "CC": cc}; - break; - default: - throw UnsupportedError("Unsupported OS: $targetOS"); - } - - var libFile = input.packageRoot.resolve(libFileName); - final gomuksBuildDir = input.packageRoot.resolve("gomuks/"); - - if (!(await File.fromUri(libFile).exists())) { - final buildDir = input.packageRoot.resolve("build/"); - libFile = buildDir.resolve("${targetArch.name}/$libFileName"); - - // goheif/dav1d supported on Android would need to fix upstream - final tags = targetOS == OS.android ? "goolm,noheic" : "goolm"; - print( - "Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) to ${libFile.path}...", - ); - final result = await Process.run( - "go", - ["build", "-tags", tags, "-o", libFile.path, "-buildmode=c-shared"], - workingDirectory: gomuksBuildDir.resolve("pkg/ffi/").toFilePath(), - environment: env.isNotEmpty ? env : null, - ); - - if (result.exitCode != 0) { - throw Exception( - "Failed to build Gomuks shared library\n${result.stderr}", - ); - } - } - - final generatedFile = "src/third_party/gomuks.g.dart"; - print("Adding $libFileName as asset..."); - output - ..assets.code.add( - CodeAsset( - package: "nexus", - name: generatedFile, - linkMode: DynamicLoadingBundled(), - file: libFile, - ), - ) - ..dependencies.add(libFile) - ..dependencies.add(gomuksBuildDir); - print("Done!"); -}); - -Future _findNdkFromSdk() async { - // pretty sure this wont be needed with nix, i'll get this removed - final androidHome = - Platform.environment["ANDROID_HOME"] ?? - Platform.environment["ANDROID_SDK_ROOT"]; - if (androidHome == null) return null; - final ndkDir = Directory("$androidHome/ndk"); - if (!await ndkDir.exists()) return null; - final versions = await ndkDir.list().toList(); - if (versions.isEmpty) return null; - versions.sort((a, b) => a.path.compareTo(b.path)); - return versions.last.path; -} - -String _ndkHostTag() { - if (Platform.isMacOS) { - return "darwin-x86_64"; - } else if (Platform.isLinux) { - return "linux-x86_64"; - } else if (Platform.isWindows) { - return "windows-x86_64"; - } - throw UnsupportedError("Unsupported host platform for Android NDK"); -} - -(String goArch, String ccTriple) _androidArch(Architecture arch) { - switch (arch) { - case Architecture.arm64: - return ("arm64", "aarch64-linux-android"); - case Architecture.arm: - return ("arm", "armv7a-linux-androideabi"); - case Architecture.x64: - return ("amd64", "x86_64-linux-android"); - case Architecture.ia32: - return ("386", "i686-linux-android"); - default: - throw UnsupportedError("Unsupported Android architecture: $arch"); - } -} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 44f96da..d4af5a1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -387,7 +387,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -519,7 +519,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -545,7 +545,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus; + PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/lib/controllers/account_data_controller.dart b/lib/controllers/account_data_controller.dart deleted file mode 100644 index 125d7cf..0000000 --- a/lib/controllers/account_data_controller.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/account_data.dart"; - -class AccountDataController extends Notifier> { - @override - IMap build() => const IMap.empty(); - - void update(IMap newData) => - state = IMap({...state.unlock, ...newData.unlock}); - - static final provider = - NotifierProvider>( - AccountDataController.new, - ); -} diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart deleted file mode 100644 index 70b7343..0000000 --- a/lib/controllers/author_controller.dart +++ /dev/null @@ -1,47 +0,0 @@ -import "dart:async"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/user_controller.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; - -class AuthorController extends AsyncNotifier { - final Message message; - AuthorController(this.message); - - @override - Future build() async { - final member = await ref.watch( - UserController.provider(message.authorId).future, - ); - - final pmp = message.metadata?["pmp"] == null - ? null - : Membership.fromContent( - IMap(message.metadata?["pmp"]), - message.authorId, - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ); - - return Membership( - status: member?.status ?? MembershipStatus.leave, - avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, - displayName: - pmp?.displayName ?? member?.displayName ?? message.authorId.localpart, - userId: message.authorId, - ); - } - - static final provider = - AsyncNotifierProvider.family( - AuthorController.new, - ); -} diff --git a/lib/controllers/avatar_controller.dart b/lib/controllers/avatar_controller.dart new file mode 100644 index 0000000..1bb4c72 --- /dev/null +++ b/lib/controllers/avatar_controller.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/client_controller.dart"; + +class AvatarController extends AsyncNotifier { + final String mxc; + AvatarController(this.mxc); + @override + Future build() async => Uri.parse(mxc).getThumbnailUri( + await ref.watch(ClientController.provider.future), + width: 24, + height: 24, + ); + + static final provider = AsyncNotifierProvider.family + .autoDispose(AvatarController.new); +} diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index cc68871..1a88526 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,280 +1,100 @@ -import "dart:developer"; -import "dart:ffi"; +import "dart:convert"; import "dart:io"; -import "dart:isolate"; -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:ffi/ffi.dart"; import "package:flutter/foundation.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/space_edges_controller.dart"; -import "package:nexus/controllers/sync_status_controller.dart"; -import "package:nexus/controllers/top_level_spaces_controller.dart"; -import "package:nexus/helpers/extensions/gomuks_buffer.dart"; -import "package:nexus/main.dart"; -import "package:nexus/models/client_state.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/paginate.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/models/requests/get_related_events_request.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; -import "package:nexus/models/requests/join_room_request.dart"; -import "package:nexus/models/requests/login_request.dart"; -import "package:nexus/models/profile.dart"; -import "package:nexus/models/requests/paginate_request.dart"; -import "package:nexus/models/requests/redact_event_request.dart"; -import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/models/requests/send_event_request.dart"; -import "package:nexus/models/requests/send_message_request.dart"; -import "package:nexus/models/requests/set_membership_request.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/models/sync_data.dart"; -import "package:nexus/models/sync_status.dart"; -import "package:nexus/src/third_party/gomuks.g.dart"; +import "package:nexus/controllers/database_controller.dart"; +import "package:flutter_vodozemac/flutter_vodozemac.dart"; +import "package:matrix/matrix.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:path_provider/path_provider.dart"; +import "package:nexus/controllers/secure_storage_controller.dart"; +import "package:nexus/models/session_backup.dart"; -class ClientController extends AsyncNotifier { +class ClientController extends AsyncNotifier { @override - Future build() async { - final Pointer root; - if (Platform.isAndroid) { - final dir = await getApplicationSupportDirectory(); - root = "${dir.path}/gomuks".toNativeUtf8().cast(); - } else { - root = nullptr.cast(); - } + bool updateShouldNotify( + AsyncValue previous, + AsyncValue next, + ) => previous.hasValue != next.hasValue; + static const sessionBackupKey = "sessionBackup"; - final handle = GomuksInit(root); - - final callable = - NativeCallable< - Void Function(Pointer, Int64, GomuksOwnedBuffer) - >.listener(( - Pointer command, - int requestId, - GomuksOwnedBuffer data, - ) { - try { - final muksEventType = command.cast().toDartString(); - debugPrint("Handling $muksEventType..."); - final decodedMuksEvent = data.toJson(); - - switch (muksEventType) { - case "client_state": - ref - .watch(ClientStateController.provider.notifier) - .set(ClientState.fromJson(decodedMuksEvent)); - break; - case "sync_status": - ref - .watch(SyncStatusController.provider.notifier) - .set(SyncStatus.fromJson(decodedMuksEvent)); - break; - case "init_complete": - ref.watch(InitCompleteController.provider.notifier).complete(); - break; - case "send_complete": - final event = Event.fromJson(decodedMuksEvent["event"]); - - if (event.type == "m.room.message") { - ref - .watch( - NewEventsController.provider(event.roomId).notifier, - ) - .add(IList([event])); - } - break; - case "sync_complete": - final syncData = SyncData.fromJson(decodedMuksEvent); - final roomProvider = RoomsController.provider; - final accountDataProvider = AccountDataController.provider; - - if (syncData.clearState) { - ref.invalidate(roomProvider); - ref.invalidate(accountDataProvider); - } - - ref - .watch(roomProvider.notifier) - .update(syncData.rooms, syncData.leftRooms); - ref - .watch(accountDataProvider.notifier) - .update(syncData.accountData); - - if (syncData.topLevelSpaces != null) { - ref - .watch(TopLevelSpacesController.provider.notifier) - .set(syncData.topLevelSpaces!); - } - - if (syncData.spaceEdges != null) { - ref - .watch(SpaceEdgesController.provider.notifier) - .set(syncData.spaceEdges!); - } - - // ref - // .watch(SyncStatusController.provider.notifier) - // .set(SyncStatus.fromJson(decodedMuksEvent)); - break; - default: - debugPrint("Unhandled event: $muksEventType"); - } - debugPrint("Finished handling $muksEventType..."); - } catch (error, stackTrace) { - debugger(); - showError(error, stackTrace); - debugPrintStack(stackTrace: stackTrace, label: error.toString()); - } - }); - - ref.onDispose(() => GomuksDestroy(handle)); - ref.onDispose(callable.close); - - final errorCode = GomuksStart(handle, callable.nativeFunction); - - if (errorCode == 0) return handle; - throw Exception("GomuksStart returned error code $errorCode"); - } - - Future _sendCommand( - String command, [ - Map data = const {}, - ]) async { - final bufferPointer = data.toGomuksBufferPtr(); - final handle = await future; - final response = await Isolate.run( - () => GomuksSubmitCommand( - handle, - command.toNativeUtf8().cast(), - bufferPointer.ref, + @override + Future build() async { + final client = Client( + "nexus", + logLevel: kReleaseMode ? Level.warning : Level.verbose, + importantStateEvents: {"im.ponies.room_emotes"}, + supportedLoginTypes: {AuthenticationTypes.password}, + database: await MatrixSdkDatabase.init( + "nexus", + database: await ref.watch(DatabaseController.provider.future), + ), + nativeImplementations: NativeImplementationsIsolate( + compute, + vodozemacInit: init, ), ); - calloc.free(bufferPointer); + final backupJson = await ref + .watch(SecureStorageController.provider.notifier) + .get(sessionBackupKey); - final json = response.buf.toJson(); - if (json is String) throw json; - return json; + if (backupJson != null) { + final backup = SessionBackup.fromJson(json.decode(backupJson)); + + await client.init( + waitForFirstSync: false, + newToken: backup.accessToken, + newHomeserver: backup.homeserver, + newUserID: backup.userID, + newDeviceID: backup.deviceID, + newDeviceName: backup.deviceName, + ); + } + + return client; } - Future redactEvent(RedactEventRequest report) => - _sendCommand("redact_event", report.toJson()); - - Future sendMessage(SendMessageRequest request) async => - Event.fromJson(await _sendCommand("send_message", request.toJson())); - - Future sendEvent(SendEventRequest request) async => - Event.fromJson(await _sendCommand("send_event", request.toJson())); - - Future verify(String recoveryKey) async { + Future setHomeserver(Uri homeserverUrl) async { + final client = await future; try { - await _sendCommand("verify", {"recovery_key": recoveryKey}); - return null; - } catch (error) { - return error.toString(); + await client.checkHomeserver(homeserverUrl); + return true; + } catch (_) { + return false; } } - Future joinRoom(JoinRoomRequest request) async { - final response = await _sendCommand("join_room", request.toJson()); - return response["room_id"]; - } - - Future getAccessToken() async { - final response = await _sendCommand("get_account_info", {}); - return response?["access_token"]; - } - - Future leaveRoom(Room room) async { - if (room.metadata == null) return; - await _sendCommand("leave_room", {"room_id": room.metadata!.id}); - } - - // (await _sendCommand("get_event_context", { - // "room_id": request.roomId, - // "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc", - // })); - - Future> getRoomState(GetRoomStateRequest request) async { - Future getState(GetRoomStateRequest request) async => - (await _sendCommand("get_room_state", request.toJson())) as List?; - final response = await getState(request); - - return (response ?? await getState(request.copyWith(refetch: true)) ?? []) - .map((event) => Event.fromJson(event)) - .toIList(); - } - - Future?> getRelatedEvents( - GetRelatedEventsRequest request, - ) async { - final response = - (await _sendCommand("get_related_events", request.toJson())) as List?; - return response?.map((event) => Event.fromJson(event)).toIList(); - } - - Future getEvent(GetEventRequest request) async { - final event = request.room.events.firstWhereOrNull( - (event) => event.eventId == request.eventId, - ); - if (event != null) return event; - - final json = await _sendCommand("get_event", request.toJson()); - return json == null ? null : Event.fromJson(json); - } - - Future paginate(PaginateRequest request) async => - Paginate.fromJson(await _sendCommand("paginate", request.toJson())); - - Future getProfile(String userId) async => - Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId})); - - Future reportEvent(ReportRequest request) => - _sendCommand("report_event", request.toJson()); - - Future setMembership(SetMembershipRequest request) => - _sendCommand("set_membership", request.toJson()); - - Future markRead(Room room) async { - final event = room.events.firstWhereOrNull( - (event) => event.rowId == room.timeline.last.eventRowId, - ); - if (event == null || room.metadata == null) return; - - await _sendCommand("mark_read", { - "room_id": room.metadata!.id, - "receipt_type": "m.read", - "event_id": event.eventId, - }); - } - - Future login(LoginRequest login) async { + Future login(String username, String password) async { + final client = await future; try { - await _sendCommand("login", login.toJson()); - return null; - } catch (error) { - return error.toString(); + final deviceName = "Nexus Client login on ${Platform.localHostname}"; + final details = await MatrixApi(homeserver: client.homeserver).login( + LoginType.mLoginPassword, + initialDeviceDisplayName: deviceName, + identifier: AuthenticationUserIdentifier(user: username), + password: password, + ); + await ref + .watch(SecureStorageController.provider.notifier) + .set( + sessionBackupKey, + json.encode( + SessionBackup( + accessToken: details.accessToken, + homeserver: client.homeserver!, + userID: details.userId, + deviceID: details.deviceId, + deviceName: deviceName, + ).toJson(), + ), + ); + ref.invalidateSelf(asReload: true); + return true; + } catch (_) { + return false; } } - Future discoverHomeserver(Uri homeserver) async { - try { - final response = await _sendCommand("discover_homeserver", { - "user_id": "@fake-user:${homeserver.host}", - }); - return response["m.homeserver"]?["base_url"]; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider( + static final provider = AsyncNotifierProvider( ClientController.new, ); } diff --git a/lib/controllers/client_state_controller.dart b/lib/controllers/client_state_controller.dart deleted file mode 100644 index 998d4a1..0000000 --- a/lib/controllers/client_state_controller.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/client_state.dart"; - -class ClientStateController extends Notifier { - @override - Null build() => null; - - void set(ClientState newState) { - state = newState; - } - - static final provider = NotifierProvider( - ClientStateController.new, - ); -} diff --git a/lib/controllers/cross_cache_controller.dart b/lib/controllers/cross_cache_controller.dart deleted file mode 100644 index 1d6d4b6..0000000 --- a/lib/controllers/cross_cache_controller.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class CrossCacheController extends Notifier { - static const String spaceKey = "space"; - static const String roomKey = "room"; - - @override - CrossCache build() => CrossCache(); - - static final provider = NotifierProvider( - CrossCacheController.new, - ); -} diff --git a/lib/controllers/database_controller.dart b/lib/controllers/database_controller.dart new file mode 100644 index 0000000..706560b --- /dev/null +++ b/lib/controllers/database_controller.dart @@ -0,0 +1,18 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:path/path.dart"; +import "package:path_provider/path_provider.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +class DatabaseController extends AsyncNotifier { + @override + Future build() async { + databaseFactory = databaseFactoryFfi; + return databaseFactoryFfi.openDatabase( + join((await getApplicationSupportDirectory()).path, "database.db"), + ); + } + + static final provider = AsyncNotifierProvider( + DatabaseController.new, + ); +} diff --git a/lib/controllers/event_controller.dart b/lib/controllers/event_controller.dart deleted file mode 100644 index 4f72963..0000000 --- a/lib/controllers/event_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/requests/get_event_request.dart"; - -class EventController extends AsyncNotifier { - final GetEventRequest request; - EventController(this.request); - - @override - Future build() async { - final client = ref.watch(ClientController.provider.notifier); - return await client.getEvent(request).onError((_, _) => null); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - EventController.new, - ); -} diff --git a/lib/controllers/events_controller.dart b/lib/controllers/events_controller.dart new file mode 100644 index 0000000..37b9ff2 --- /dev/null +++ b/lib/controllers/events_controller.dart @@ -0,0 +1,30 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/from_controller.dart"; + +class EventsController extends AsyncNotifier { + EventsController(this.room); + final Room room; + + @override + Future build({String? from}) async { + final response = await room.client.getRoomEvents( + room.id, + Direction.b, + from: from, + limit: 32, + ); + if (ref.mounted) { + ref.watch(FromController.provider(room).notifier).set(response.end); + } + return response; + } + + Future prev() async => + build(from: ref.read(FromController.provider(room))); + + static final provider = AsyncNotifierProvider.autoDispose + .family( + EventsController.new, + ); +} diff --git a/lib/controllers/from_controller.dart b/lib/controllers/from_controller.dart new file mode 100644 index 0000000..54c850a --- /dev/null +++ b/lib/controllers/from_controller.dart @@ -0,0 +1,15 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; + +class FromController extends Notifier { + FromController(_); + @override + String? build() => null; + + void set(String? value) => state = value; + + static final provider = + NotifierProvider.family( + FromController.new, + ); +} diff --git a/lib/controllers/header_controller.dart b/lib/controllers/header_controller.dart deleted file mode 100644 index 295cf04..0000000 --- a/lib/controllers/header_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; - -class HeaderController extends AsyncNotifier> { - @override - Future> build() async { - if (ref.watch(ClientStateController.provider)?.isLoggedIn != true) { - return {}; - } - final client = ref.watch(ClientController.provider.notifier); - final accessToken = await client.getAccessToken(); - return {"authorization": "Bearer $accessToken"}; - } - - static final provider = - AsyncNotifierProvider>( - HeaderController.new, - ); -} diff --git a/lib/controllers/init_complete_controller.dart b/lib/controllers/init_complete_controller.dart deleted file mode 100644 index c011472..0000000 --- a/lib/controllers/init_complete_controller.dart +++ /dev/null @@ -1,11 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class InitCompleteController extends Notifier { - @override - bool build() => false; - void complete() => state = true; - - static final provider = NotifierProvider( - InitCompleteController.new, - ); -} diff --git a/lib/controllers/members_by_type_controller.dart b/lib/controllers/members_by_type_controller.dart deleted file mode 100644 index cdc8d07..0000000 --- a/lib/controllers/members_by_type_controller.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; - -class MembersByTypeController extends AsyncNotifier> { - final MembershipStatus status; - MembersByTypeController(this.status); - - @override - Future> build() => ref.watch( - MembersController.provider.selectAsync( - (members) => - members.where((membership) => membership.status == status).toIList(), - ), - ); - - static final provider = - AsyncNotifierProvider.family< - MembersByTypeController, - IList, - MembershipStatus - >(MembersByTypeController.new); -} diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index 39666d4..fae5433 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,52 +1,22 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; +import "package:matrix/matrix.dart"; + +class MembersController extends AsyncNotifier> { + final Room room; + MembersController(this.room); -class MembersController extends AsyncNotifier> { @override - Future> build() async { - final data = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata == null - ? null - : (value!.metadata!.id, value.metadata!.hasMemberList), - ), - ); - if (data == null) return const IList.empty(); - - final state = await ref - .watch(ClientController.provider.notifier) - .getRoomState( - GetRoomStateRequest( - roomId: data.$1, - fetchMembers: data.$2 == false, - includeMembers: true, - ), - ); - - return state.nonNulls - .where((state) => state.type == "m.room.member") - .map( - (membership) => Membership.fromContent( - membership.content, - membership.stateKey!, - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ), - ) - .toIList(); - } + Future> build() async => IList( + (await room.client.getMembersByRoom( + room.id, + notMembership: Membership.leave, + )) ?? + [], + ); static final provider = - AsyncNotifierProvider>( + AsyncNotifierProvider.family, Room>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart deleted file mode 100644 index c65d18d..0000000 --- a/lib/controllers/message_controller.dart +++ /dev/null @@ -1,214 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_related_events_request.dart"; - -class MessageController extends AsyncNotifier { - final MessageConfig config; - MessageController(this.config); - - @override - Future build() async { - try { - final isEdit = config.event.relationType == "m.replace"; - if ((isEdit && !config.includeEdits) || config.room.metadata == null) { - return null; - } - - final event = config.event.lastEditRowId == null - ? config.event - : config.room.events.firstWhereOrNull( - (e) => e.rowId == config.event.lastEditRowId, - ) ?? - config.event; - - final decrypted = (event.decrypted ?? event.content); - final type = (config.event.decryptedType ?? config.event.type); - final content = decrypted["m.new_content"] == null - ? decrypted - : IMap(decrypted["m.new_content"]); - - final homeserver = ref - .read(ClientStateController.provider) - ?.homeserverUrl; - final source = homeserver == null || content["url"] == null - ? "null" - : Uri.parse(content["url"]).mxcToHttps(homeserver).toString(); - - final metadata = { - "body": config.event.redactedBy == null - ? (content["body"] ?? "") - : "Deleted Message", - "flashing": false, - "timelineId": event.timelineRowId, - "big": event.localContent?.bigEmoji == true, - "eventType": type, - "pmp": content["com.beeper.per_message_profile"], - "error": event.sendError, - "format": content["format"] ?? content["format"], - "editSource": event.localContent?.editSource ?? content["body"], - "txnId": config.event.transactionId, - }; - - final editedAt = event.relationType == "m.replace" - ? event.timestamp - : null; - - if ((event.redactedBy != null && !config.alwaysReturn) || - (!config.includeEdits && - (config.event.relationType == "m.replace"))) { - return null; - } - - final replyId = - config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; - - final reactionEvents = config.event.reactions.isEmpty && !isEdit - ? null - : await ref - .watch(ClientController.provider.notifier) - .getRelatedEvents( - GetRelatedEventsRequest( - roomId: config.room.metadata!.id, - eventId: - (isEdit ? config.event.relatesTo : null) ?? - config.event.eventId, - relationType: "m.annotation", - ), - ); - - final reactions = reactionEvents - ?.where((event) => event.redactedBy == null) - .fold>>(IMap(), (acc, event) { - final key = event.content["m.relates_to"]?["key"]; - if (key == null) return acc; - - return acc.update( - key, - (list) => list.add(event.authorId), - ifAbsent: () => IList([event.authorId]), - ); - }) - .map((key, value) => MapEntry(key, value.unlock)) - .unlock; - - final asText = - Message.text( - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - text: content["formatted_body"] ?? content["body"] ?? "", - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - editedAt: editedAt, - ) - as TextMessage; - - Message toSystemMessage(String content) => Message.system( - metadata: {...metadata, "body": content}, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - deliveredAt: config.event.timestamp, - text: content, - ); - - return switch (type) { - "m.room.encrypted" => asText.copyWith( - text: "Unable to decrypt message.", - metadata: {...metadata, "body": "Unable to decrypt message."}, - ), - // "org.matrix.msc3381.poll.start" => Message.custom( - // metadata: { - // ...metadata, - // "poll": event.parsedPollEventContent.pollStartContent, - // "responses": event.getPollResponses(timeline), - // }, - // id: eventId, - // deliveredAt: originServerTs, - // authorId: senderId, - // ), - ("m.sticker" || "m.room.message") => switch (content["msgtype"]) { - null || "m.image" => Message.image( - id: config.event.eventId, - authorId: event.authorId, - reactions: reactions, - source: source, - replyToMessageId: replyId, - metadata: metadata, - text: asText.text, - deliveredAt: config.event.timestamp, - blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"], - ), - "m.audio" || "m.file" => Message.file( - name: content["filename"].toString(), - size: content["info"]["size"], - metadata: metadata, - id: config.event.eventId, - reactions: reactions, - authorId: event.authorId, - source: source, - replyToMessageId: replyId, - deliveredAt: config.event.timestamp, - ), - _ => asText, - }, - "m.room.member" => - content["membership"] == event.unsigned["prev_content"]?["membership"] - ? null - : toSystemMessage( - "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { - "invite" => "was invited to", - "join" => "joined", - "leave" => event.authorId == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), - "ban" => "was banned from", - "knock" => "asked to join", - _ => "did something relating to", - }} the room. ${content["reason"] ?? ""}", - ), - - "m.room.server_acl" => toSystemMessage( - "${event.authorId} updated the server ban list.", - ), - - "m.room.redaction" => - config.alwaysReturn - ? asText.copyWith( - metadata: { - ...(asText.metadata ?? {}), - "body": "Deleted Message", - }, - ) - : null, - _ => - config.alwaysReturn - ? asText - : ( - // Turn this on for debugging purposes - false - // ignore: dead_code - ? Message.unsupported( - metadata: metadata, - reactions: reactions, - id: config.event.eventId, - authorId: event.authorId, - replyToMessageId: replyId, - ) - : null), - }; - } catch (error) { - return null; - } - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - MessageController.new, - ); -} diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart deleted file mode 100644 index 28885fb..0000000 --- a/lib/controllers/messages_controller.dart +++ /dev/null @@ -1,27 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/configs/messages_config.dart"; - -class MessagesController extends AsyncNotifier> { - final MessagesConfig config; - MessagesController(this.config); - - @override - Future> build() async => (await Future.wait( - config.events.map( - (event) => ref.watch( - MessageController.provider( - MessageConfig(event: event, room: config.room), - ).future, - ), - ), - )).nonNulls.toIList(); - - static final provider = AsyncNotifierProvider.family - .autoDispose, MessagesConfig>( - MessagesController.new, - ); -} diff --git a/lib/controllers/multi_provider_controller.dart b/lib/controllers/multi_provider_controller.dart deleted file mode 100644 index e23ecaa..0000000 --- a/lib/controllers/multi_provider_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "dart:async"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class MultiProviderController extends AsyncNotifier { - MultiProviderController(this.providers); - final IList providers; - - @override - FutureOr build() async => await Future.wait( - providers.map((provider) => ref.watch(provider.future)), - ); - - static final provider = - AsyncNotifierProvider.family< - MultiProviderController, - void, - IList - >(MultiProviderController.new); -} diff --git a/lib/controllers/new_events_controller.dart b/lib/controllers/new_events_controller.dart deleted file mode 100644 index 215ebd3..0000000 --- a/lib/controllers/new_events_controller.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; - -class NewEventsController extends Notifier> { - final String roomId; - NewEventsController(this.roomId); - - @override - IList build() => const IList.empty(); - - void add(IList newEvents) => state = newEvents; - - static final provider = NotifierProvider.autoDispose - .family, String>( - NewEventsController.new, - ); -} diff --git a/lib/controllers/power_level_controller.dart b/lib/controllers/power_level_controller.dart deleted file mode 100644 index 41b5f19..0000000 --- a/lib/controllers/power_level_controller.dart +++ /dev/null @@ -1,70 +0,0 @@ -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/requests/membership_action.dart"; - -class PowerLevelController extends Notifier { - final PowerLevelConfig config; - PowerLevelController(this.config); - - @override - bool build() { - final room = ref.watch(SelectedRoomController.provider); - final event = room?.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], - ); - final user = ref.watch(ClientStateController.provider)?.userId; - if (event == null || user == null) return false; - - final users = (event.content["users"] as Map? ?? {}); - final events = (event.content["events"] as Map? ?? {}); - - int powerLevelOf(String userId) => users.containsKey(userId) - ? (users[userId] as int) - : (event.content["users_default"] as int? ?? 0); - - final userLevel = powerLevelOf(user); - final targetLevel = config.targetUser != null - ? powerLevelOf(config.targetUser!) - : null; - - if (config.action != null) { - return switch (config.action!) { - MembershipAction.invite => - userLevel >= (event.content["invite"] as int? ?? 0), - - MembershipAction.kick => - targetLevel != null && - userLevel >= (event.content["kick"] as int? ?? 50) && - userLevel > targetLevel, - - MembershipAction.ban => - targetLevel != null && - userLevel >= (event.content["ban"] as int? ?? 50) && - userLevel > targetLevel, - - MembershipAction.unban => - userLevel >= (event.content["ban"] as int? ?? 50), - }; - } - - if (config.eventType == "m.room.redaction") { - return userLevel >= (event.content["redact"] as int? ?? 50); - } - - final requiredLevel = events.containsKey(config.eventType) - ? (events[config.eventType] as int) - : (config.isStateEvent - ? (event.content["state_default"] as int? ?? 50) - : (event.content["events_default"] as int? ?? 0)); - - return userLevel >= requiredLevel; - } - - static final provider = NotifierProvider.autoDispose - .family( - PowerLevelController.new, - ); -} diff --git a/lib/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart deleted file mode 100644 index 120d4e4..0000000 --- a/lib/controllers/profile_controller.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/models/profile.dart"; - -class ProfileController extends AsyncNotifier { - final String userId; - ProfileController(this.userId); - - @override - Future build() { - final client = ref.watch(ClientController.provider.notifier); - return client.getProfile(userId); - } - - static final provider = AsyncNotifierProvider.autoDispose - .family(ProfileController.new); -} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index fa32bf8..1a1be39 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,190 +1,58 @@ -import "dart:async"; import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:fluttertagger/fluttertagger.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/messages_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/models/configs/messages_config.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_related_events_request.dart"; -import "package:nexus/models/requests/get_room_state_request.dart"; -import "package:nexus/models/requests/paginate_request.dart"; -import "package:nexus/models/requests/redact_event_request.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/avatar_controller.dart"; +import "package:nexus/controllers/events_controller.dart"; +import "package:nexus/helpers/extensions/event_to_message.dart"; +import "package:nexus/helpers/extensions/list_to_messages.dart"; +import "package:fluttertagger/fluttertagger.dart" as tagger; import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/requests/send_event_request.dart"; -import "package:nexus/models/requests/send_message_request.dart"; -import "package:nexus/models/room.dart"; -class RoomChatController extends AsyncNotifier { - final String roomId; - RoomChatController(this.roomId); +class RoomChatController extends AsyncNotifier { + final Room room; + RoomChatController(this.room); @override - Future build() async { - final client = ref.watch(ClientController.provider.notifier); - var room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - final state = await client.getRoomState( - GetRoomStateRequest(roomId: roomId), - ); - - ref - .read(RoomsController.provider.notifier) - .update( - { - roomId: Room( - events: state, - state: state.fold( - const IMap.empty(), - (previousValue, stateEvent) => previousValue.add( - stateEvent.type, - (previousValue[stateEvent.type] ?? const IMap.empty()).addAll( - IMap({ - if (stateEvent.stateKey != null) - stateEvent.stateKey!: stateEvent.rowId, - }), - ), - ), - ), - ), - }.toIMap(), - const ISet.empty(), - ); - - room = ref.read(RoomsController.provider)[roomId]; - if (room == null) return InMemoryChatController(); - - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig( - room: room, - events: room.timeline - .map( - (timelineRowTuple) => room!.events.firstWhereOrNull( - (event) => event.rowId == timelineRowTuple.eventRowId, - ), - ) - .nonNulls - .toIList(), - ), - ).future, - ); - final controller = InMemoryChatController(messages: messages.toList()); + Future build() async { + final response = await ref.watch(EventsController.provider(room).future); ref.onDispose( - ref.listen(NewEventsController.provider(roomId), (_, next) async { - for (final event in next) { - if (event.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == event.content["m.relates_to"]?["event_id"], - ); - final key = event.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; + room.client.onTimelineEvent.stream.listen((event) async { + if (event.roomId != room.id) return; - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => [...reactors, event.authorId], - ifAbsent: () => [event.authorId], - ) - .unlock, - ), + if (event.type == EventTypes.Redaction) { + final controller = await future; + final message = controller.messages.firstWhereOrNull( + (message) => message.id == event.redacts, + ); + if (message == null) return; + + await controller.removeMessage(message); + } else { + final message = await event.toMessage(includeEdits: true); + if (event.relationshipType == RelationshipTypes.edit) { + final controller = await future; + final oldMessage = controller.messages.firstWhereOrNull( + (element) => element.id == event.relationshipEventId, + ); + if (oldMessage == null || message == null) return; + return await updateMessage( + oldMessage, + message.copyWith(id: oldMessage.id), ); } - - if (event.type == "m.room.redaction") { - final controller = await future; - final redactsId = event.content["redacts"]; - final originalMessage = controller.messages.firstWhereOrNull( - (message) => message.id == redactsId, - ); - if (!ref.mounted) return; - - if (originalMessage != null) { - return await controller.removeMessage(originalMessage); - } - - final redacts = ref - .read(SelectedRoomController.provider) - ?.events - .firstWhere((event) => event.eventId == redactsId); - - if (redacts?.type == "m.reaction") { - final message = controller.messages.firstWhereOrNull( - (message) => - message.id == redacts!.content["m.relates_to"]?["event_id"], - ); - final key = redacts!.content["m.relates_to"]?["key"]; - if (message == null || key == null || !ref.mounted) return; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => - IList(reactors).remove(redacts.authorId).unlock, - ) - .where((_, value) => value.isNotEmpty) - .unlock, - ), - ); - } - } else { - final message = await ref.watch( - MessageController.provider( - MessageConfig(event: event, room: room!, includeEdits: true), - ).future, - ); - if (event.relationType == "m.replace") { - final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.id == event.relatesTo, - ); - if (oldMessage == null || message == null || !ref.mounted) return; - - return await controller.updateMessage( - oldMessage, - message.copyWith( - id: oldMessage.id, - replyToMessageId: oldMessage.replyToMessageId, - metadata: { - ...(oldMessage.metadata ?? {}), - ...(message.metadata ?? {}) - .toIMap() - .where((key, value) => value != null) - .unlock, - }, - ), - ); - } - if (message != null && ref.mounted) { - await insertMessage(message); - } + if (message != null) { + return await insertMessage(message); } } - }, weak: true).close, + }).cancel, ); - ref.onDispose(controller.dispose); - - // While there are under 20 messages, try up to load more messages until theres no more or we have 20 messages. - for (var more = true; more == true && controller.messages.length < 20;) { - more = await loadOlder(controller); - } - - return controller; + return InMemoryChatController( + messages: await response.chunk.toMessages(room), + ); } Future insertMessage(Message message) async { @@ -201,78 +69,42 @@ class RoomChatController extends AsyncNotifier { : controller.updateMessage(oldMessage, message); } - Future deleteMessage(Message message, {String? reason}) => ref - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason), - ); - - Future loadOlder([InMemoryChatController? chatController]) async { - final response = await ref - .watch(ClientController.provider.notifier) - .paginate( - PaginateRequest( - roomId: roomId, - maxTimelineId: ref - .read(RoomsController.provider)[roomId] - ?.timeline - .firstOrNull - ?.timelineRowId, - ), - ); - - ref - .watch(RoomsController.provider.notifier) - .update( - IMap({ - roomId: Room( - events: response.events.addAll(response.relatedEvents), - hasMore: response.hasMore, - timeline: response.events - .map( - (event) => TimelineRowTuple( - timelineRowId: event.timelineRowId, - eventRowId: event.rowId, - ), - ) - .toIList(), - ), - }), - const ISet.empty(), - addToNewEvents: false, - ); - - final room = ref.read(RoomsController.provider)[roomId]; - if (room != null) { - final messages = await ref.watch( - MessagesController.provider( - MessagesConfig(room: room, events: response.events.reversed), - ).future, - ); - - final controller = chatController ?? await future; - await controller.insertAllMessages( - messages - .where( - (newMessage) => !controller.messages.any( - (message) => message.id == newMessage.id, - ), - ) - .toList(), - index: 0, - ); - } - return response.hasMore; + Future deleteMessage(Message message, {String? reason}) async { + final controller = await future; + await controller.removeMessage(message); + await room.redactEvent(message.id, reason: reason); } + Future loadOlder() async { + final controller = await future; + final response = await ref + .watch(EventsController.provider(room).notifier) + .prev(); + + final messages = await response.chunk.toMessages(room); + + await controller.insertAllMessages(messages, index: 0); + ref.notifyListeners(); + } + + Future markRead() async { + if (!room.hasNewMessages) return; + final controller = await future; + final id = controller.messages.last.id; + + await room.setReadMarker(id, mRead: id); + } + + Future updateMessage(Message message, Message newMessage) async => + (await future).updateMessage(message, newMessage); + Future send( - String text, { - bool shouldMention = true, - required IList tags, + String message, { + required Iterable tags, required RelationType relationType, Message? relation, }) async { - var taggedMessage = text; + var taggedMessage = message; for (final tag in tags) { final escaped = RegExp.escape(tag.id); @@ -284,107 +116,30 @@ class RoomChatController extends AsyncNotifier { ); } - final client = ref.watch(ClientController.provider.notifier); - final room = ref.read(RoomsController.provider)[roomId]; - final event = await client.sendMessage( - SendMessageRequest( - roomId: roomId, - mentions: Mentions( - userIds: [ - if (shouldMention == true && - relation != null && - relationType == RelationType.reply) - relation.authorId, - ].toIList(), - room: taggedMessage.contains("@room"), - ), - text: taggedMessage, - relation: relation == null - ? null - : Relation(eventId: relation.id, relationType: relationType), - ), + await room.sendTextEvent( + taggedMessage, + editEventId: relationType == RelationType.edit ? relation?.id : null, + inReplyTo: (relationType == RelationType.reply && relation != null) + ? await room.getEventById(relation.id) + : null, ); - final message = room == null - ? null - : await ref.watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ).future, - ); - - if (message != null) insertMessage(message); } - Future scrollToMessage(Message message) async { - final controller = await future; - Future setFlashing(bool flashing) => controller.updateMessage( - message, - message.copyWith( - metadata: {...(message.metadata ?? {}), "flashing": flashing}, - ), - ); - - await setFlashing(true); - Timer(Duration(seconds: 1), () => setFlashing(false)); - - return await controller.scrollToMessage(message.id); - } - - Future removeReaction( - String reaction, - Message message, - String userId, - ) async { - final client = ref.watch(ClientController.provider.notifier); - final allReactionEvents = await client.getRelatedEvents( - GetRelatedEventsRequest( - roomId: roomId, - eventId: message.id, - relationType: "m.annotation", - ), - ); - - final reactionEvents = allReactionEvents - ?.where((event) => event.redactedBy == null) - .toIList(); - - final reactionEvent = reactionEvents?.firstWhereOrNull( - (event) => - event.authorId == userId && - event.content["m.relates_to"]?["key"] == reaction, - ); - - if (reactionEvent != null) { - await ref - .watch(ClientController.provider.notifier) - .redactEvent( - RedactEventRequest(eventId: reactionEvent.eventId, roomId: roomId), - ); - } - } - - Future sendReaction(String reaction, Message message) async { - final client = ref.watch(ClientController.provider.notifier); - - await client.sendEvent( - SendEventRequest( - roomId: roomId, - type: "m.reaction", - content: { - "m.relates_to": { - "event_id": message.id, - "rel_type": "m.annotation", - "key": reaction, - }, - }, - synchronous: true, - disableEncryption: true, - ), + Future resolveUser(String id) async { + final user = await room.client.getUserProfile(id); + return chat.User( + id: id, + name: user.displayname, + imageSource: user.avatarUrl == null + ? null + : (await ref.watch( + AvatarController.provider(user.avatarUrl!.toString()).future, + )).toString(), ); } static final provider = AsyncNotifierProvider.family - .autoDispose( + .autoDispose( RoomChatController.new, ); } diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 7013de0..864d656 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,109 +1,23 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/new_events_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/read_receipt.dart"; -import "package:nexus/models/room.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/helpers/extensions/get_full_room.dart"; +import "package:nexus/models/full_room.dart"; -class RoomsController extends Notifier> { +class RoomsController extends AsyncNotifier> { @override - IMap build() => const IMap.empty(); + Future> build() async { + final client = await ref.watch(ClientController.provider.future); - void update( - IMap rooms, - ISet leftRooms, { - bool addToNewEvents = true, - }) { - final homeserver = - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - ""; - final merged = rooms.entries.fold(state, (acc, entry) { - final roomId = entry.key; - final incoming = entry.value; - final existing = acc[roomId]; - - final events = existing?.events.updateById( - incoming.events, - (item) => item.eventId, - ); - - if (addToNewEvents) { - ref - .watch(NewEventsController.provider(roomId).notifier) - .add( - incoming.timeline - .map( - (timelineTuple) => events?.firstWhereOrNull( - (event) => timelineTuple.eventRowId == event.rowId, - ), - ) - .nonNulls - .toIList(), - ); - } - - return acc.add( - roomId, - existing?.copyWith( - hasMore: incoming.hasMore, - metadata: - incoming.metadata?.copyWith( - avatar: - incoming.metadata?.avatar?.mxcToHttps(homeserver) ?? - existing.metadata?.avatar, - ) ?? - existing.metadata, - events: events!, - state: incoming.state.entries.fold( - existing.state, - (previousValue, event) => previousValue.add( - event.key, - (previousValue[event.key] ?? const IMap.empty()).addAll( - event.value, - ), - ), - ), - timeline: - (incoming.reset - ? incoming.timeline - : existing.timeline.updateById( - incoming.timeline, - (item) => item.timelineRowId, - )) - .sortedBy((element) => element.timelineRowId) - .toIList(), - receipts: incoming.receipts.entries.fold( - existing.receipts, - (receiptAcc, event) => receiptAcc.add( - event.key, - (receiptAcc[event.key] ?? IList()).addAll( - event.value, - ), - ), - ), - ) ?? - incoming.copyWith( - metadata: incoming.metadata?.copyWith( - avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver), - ), - ), - ); - }); - - final prunedList = leftRooms.fold( - merged, - (acc, roomId) => acc.remove(roomId), + ref.onDispose( + client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, ); - state = prunedList; + + return IList(await Future.wait(client.rooms.map((room) => room.fullRoom))); } - static final provider = NotifierProvider>( - RoomsController.new, - ); + static final provider = + AsyncNotifierProvider>( + RoomsController.new, + ); } diff --git a/lib/controllers/secure_storage_controller.dart b/lib/controllers/secure_storage_controller.dart new file mode 100644 index 0000000..8a579f5 --- /dev/null +++ b/lib/controllers/secure_storage_controller.dart @@ -0,0 +1,26 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:simple_secure_storage/simple_secure_storage.dart"; + +class SecureStorageController extends AsyncNotifier { + @override + Future build() => SimpleSecureStorage.initialize(); + + Future get(String key) async { + await future; + return SimpleSecureStorage.read(key); + } + + Future set(String key, String value) async { + await future; + return SimpleSecureStorage.write(key, value); + } + + Future clear() async { + await future; + return SimpleSecureStorage.clear(); + } + + static final provider = AsyncNotifierProvider( + SecureStorageController.new, + ); +} diff --git a/lib/controllers/selected_room_controller.dart b/lib/controllers/selected_room_controller.dart index ffba78c..cfeead6 100644 --- a/lib/controllers/selected_room_controller.dart +++ b/lib/controllers/selected_room_controller.dart @@ -2,23 +2,24 @@ import "package:collection/collection.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/selected_space_controller.dart"; -import "package:nexus/models/room.dart"; +import "package:nexus/models/full_room.dart"; -class SelectedRoomController extends Notifier { +class SelectedRoomController extends AsyncNotifier { @override - Room? build() { - final space = ref.watch(SelectedSpaceController.provider); + Future build() async { + final space = await ref.watch(SelectedSpaceController.provider.future); final selectedRoomId = ref.watch( KeyController.provider(KeyController.roomKey), ); return space.children.firstWhereOrNull( - (room) => room.metadata?.id == selectedRoomId, + (room) => room.roomData.id == selectedRoomId, ) ?? space.children.firstOrNull; } - static final provider = NotifierProvider( - SelectedRoomController.new, - ); + static final provider = + AsyncNotifierProvider( + SelectedRoomController.new, + ); } diff --git a/lib/controllers/selected_space_controller.dart b/lib/controllers/selected_space_controller.dart index dbeb71f..75bf287 100644 --- a/lib/controllers/selected_space_controller.dart +++ b/lib/controllers/selected_space_controller.dart @@ -4,10 +4,12 @@ import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/models/space.dart"; -class SelectedSpaceController extends Notifier { +class SelectedSpaceController extends AsyncNotifier { @override - Space build() { - final spaces = ref.watch(SpacesController.provider); + Future build() async { + final spaces = await ref.watch( + SpacesController.provider.selectAsync((data) => data), + ); final selectedSpaceId = ref.watch( KeyController.provider(KeyController.spaceKey), ); @@ -16,7 +18,7 @@ class SelectedSpaceController extends Notifier { spaces.first; } - static final provider = NotifierProvider( + static final provider = AsyncNotifierProvider( SelectedSpaceController.new, ); } diff --git a/lib/controllers/space_edges_controller.dart b/lib/controllers/space_edges_controller.dart deleted file mode 100644 index 12694d6..0000000 --- a/lib/controllers/space_edges_controller.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/space_edge.dart"; - -class SpaceEdgesController extends Notifier>> { - @override - IMap> build() => const IMap.empty(); - - void set(IMap> newEdges) => - state = state.addAll(newEdges); - - static final provider = - NotifierProvider>>( - SpaceEdgesController.new, - ); -} diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index 7a503ad..3501de6 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -1,139 +1,77 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/top_level_spaces_controller.dart"; -import "package:nexus/controllers/space_edges_controller.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/helpers/extensions/get_full_room.dart"; +import "package:nexus/helpers/extensions/room_to_children.dart"; import "package:nexus/models/space.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/models/space_edge.dart"; -class SpacesController extends Notifier> { +class SpacesController extends AsyncNotifier> { @override - IList build() { - final rooms = ref.watch(RoomsController.provider); - final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); - final spaceEdges = ref.watch(SpaceEdgesController.provider); + Future> build() async { + final client = await ref.watch(ClientController.provider.future); - final childRoomsBySpaceId = IMap.fromEntries( - topLevelSpaceIds.map((spaceId) { - ISet walk(String currentId) { - final children = spaceEdges[currentId] ?? IList(); - - return children.fold>(const ISet.empty(), (acc, edge) { - final childId = edge.childId; - final isSpace = spaceEdges.containsKey(childId); - - return acc - .addAll(!isSpace ? ISet([childId]) : const ISet.empty()) - .addAll(isSpace ? walk(childId) : const ISet.empty()); - }); - } - - return MapEntry( - spaceId, - walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(), - ); - }), + ref.onDispose( + client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, ); - final allNestedRoomIds = childRoomsBySpaceId.values - .expand((l) => l) - .map( - (room) => rooms.entries - .firstWhere( - (entry) => entry.value.metadata?.id == room.metadata?.id, - ) - .key, - ) - .toISet(); + final topLevel = IList( + await Future.wait( + client.rooms + .where((room) => !room.isDirectChat) + .where( + (room) => client.rooms + .where((room) => room.isSpace) + .every( + (match) => match.spaceChildren.every( + (child) => child.roomId != room.id, + ), + ), + ) + .map((room) => room.fullRoom), + ), + ); - final otherRooms = rooms.entries - .where( - (e) => - !allNestedRoomIds.contains(e.key) && - !topLevelSpaceIds.contains(e.key) && - !spaceEdges.containsKey(e.key), - ) - .map((e) => e.value); + final topLevelSpaces = topLevel.where((r) => r.roomData.isSpace).toIList(); + final topLevelRooms = topLevel.where((r) => !r.roomData.isSpace).toIList(); - final accountData = ref.watch(AccountDataController.provider); - - final directMessages = IMap( - accountData["m.direct"]?.content ?? {}, - ).values.expand((element) => element); - - final homeRooms = otherRooms - .where( - (room) => - directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ) == - false, - ) - .toIList(); - - final dmRooms = otherRooms - .where( - (room) => directMessages.any( - (directMessage) => directMessage == room.metadata?.id, + return IList([ + Space( + client: client, + title: "Home", + id: "home", + children: topLevelRooms, + icon: Icons.home, + ), + Space( + client: client, + title: "Direct Messages", + id: "dms", + children: IList( + await Future.wait( + client.rooms + .where((room) => room.isDirectChat) + .map((room) => room.fullRoom), ), - ) - .toIList(); - - final topLevelSpacesList = topLevelSpaceIds - .map((id) { - final room = rooms[id]; - if (room == null) return null; - - final children = childRoomsBySpaceId[id] ?? IList(); - return Space( - id: id, - title: room.metadata?.name ?? "Unnamed Room", - room: room, - children: children, - ); - }) - .nonNulls - .toIList(); - - return [ - Space( - id: "home", - title: "Home", - icon: Icons.home, - children: homeRooms, + ), + icon: Icons.person, + ), + ...(await Future.wait( + topLevelSpaces.map( + (space) async => Space( + client: client, + title: space.title, + avatar: space.avatar, + id: space.roomData.id, + roomData: space.roomData, + children: IList(await space.roomData.getAllChildren(client)), ), - Space( - id: "dms", - title: "Direct Messages", - icon: Icons.people, - children: dmRooms, - ), - ...topLevelSpacesList, - ] - .map( - (space) => space.copyWith( - children: space.children - .sortedBy( - (element) => - element - .metadata - ?.sortingTimestamp - .millisecondsSinceEpoch ?? - 0, - ) - .sortedBy((room) => room.metadata?.unreadMessages ?? 0) - .reversed - .toIList(), - ), - ) - .toIList(); + ), + )), + ]); } - static final provider = NotifierProvider>( + static final provider = AsyncNotifierProvider>( SpacesController.new, ); } diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status_controller.dart deleted file mode 100644 index 8475d9d..0000000 --- a/lib/controllers/sync_status_controller.dart +++ /dev/null @@ -1,19 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/main.dart"; -import "package:nexus/models/sync_status.dart"; - -class SyncStatusController extends Notifier { - @override - Null build() => null; - - void set(SyncStatus newStatus) { - if (newStatus.type == SyncStatusType.permanentlyFailed) { - showError(newStatus.error ?? "Syncing failed"); - } - state = newStatus; - } - - static final provider = NotifierProvider( - SyncStatusController.new, - ); -} diff --git a/lib/controllers/thumbnail_controller.dart b/lib/controllers/thumbnail_controller.dart new file mode 100644 index 0000000..4500523 --- /dev/null +++ b/lib/controllers/thumbnail_controller.dart @@ -0,0 +1,22 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/models/image_data.dart"; + +class ThumbnailController extends AsyncNotifier { + ThumbnailController(this.data); + final ImageData data; + + @override + Future build({String? from}) async { + final client = await ref.watch(ClientController.provider.future); + final uri = await Uri.tryParse(data.uri)?.getDownloadUri(client); + + return uri.toString(); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + ThumbnailController.new, + ); +} diff --git a/lib/controllers/top_level_spaces_controller.dart b/lib/controllers/top_level_spaces_controller.dart deleted file mode 100644 index e1f9c88..0000000 --- a/lib/controllers/top_level_spaces_controller.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class TopLevelSpacesController extends Notifier> { - @override - IList build() => const IList.empty(); - - void set(IList newSpaces) => state = newSpaces; - - static final provider = - NotifierProvider>( - TopLevelSpacesController.new, - ); -} diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart deleted file mode 100644 index c2161d5..0000000 --- a/lib/controllers/url_preview_controller.dart +++ /dev/null @@ -1,60 +0,0 @@ -import "dart:convert"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:http/http.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/header_controller.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; - -class UrlPreviewController extends AsyncNotifier { - final String link; - UrlPreviewController(this.link); - - @override - Future build() async { - final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl; - - if (homeserver != null && !link.contains("matrix.to")) { - { - final response = await get( - Uri.parse(homeserver) - .resolve("/_matrix/client/v1/media/preview_url") - .replace(queryParameters: {"url": link}), - headers: await ref.watch(HeaderController.provider.future), - ); - - if (response.statusCode == 200) { - final decodedValue = json.decode(response.body); - final mxc = decodedValue["og:image"]; - final image = mxc == null - ? null - : Uri.tryParse(mxc)?.mxcToHttps(homeserver); - - return LinkPreviewData( - link: link, - title: decodedValue["og:title"], - description: decodedValue["og:description"], - image: image == null - ? null - : ImagePreviewData( - url: image.toString(), - width: - (decodedValue["og:image:width"] as int?)?.toDouble() ?? - 0, - height: - (decodedValue["og:image:height"] as int?)?.toDouble() ?? - 0, - ), - ); - } - } - } - - return null; - } - - static final provider = AsyncNotifierProvider.autoDispose - .family( - UrlPreviewController.new, - ); -} diff --git a/lib/controllers/user_controller.dart b/lib/controllers/user_controller.dart deleted file mode 100644 index e7ca973..0000000 --- a/lib/controllers/user_controller.dart +++ /dev/null @@ -1,40 +0,0 @@ -import "dart:async"; -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; - -class UserController extends AsyncNotifier { - final String userId; - UserController(this.userId); - - @override - Future build() async { - final member = await ref.watch( - MembersController.provider.selectAsync( - (value) => - value.firstWhereOrNull((membership) => membership.userId == userId), - ), - ); - - if (member != null) return member; - - final profile = await ref.watch(ProfileController.provider(userId).future); - return Membership( - status: MembershipStatus.leave, - avatarUrl: profile.avatarUrl == null - ? null - : Uri.tryParse(profile.avatarUrl!), - displayName: profile.displayName ?? userId.localpart, - userId: userId, - ); - } - - static final provider = - AsyncNotifierProvider.family( - UserController.new, - ); -} diff --git a/lib/controllers/via_controller.dart b/lib/controllers/via_controller.dart deleted file mode 100644 index b423947..0000000 --- a/lib/controllers/via_controller.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/models/room.dart"; - -class ViaController extends Notifier { - final Room room; - ViaController(this.room); - - @override - String build() { - final servers = {}; - - void addUserId(String? userId) { - final server = userId?.split(":").lastOrNull; - if (server != null) { - servers.add(server); - } - } - - addUserId(ref.watch(ClientStateController.provider)?.userId); - - final powerLevels = room.events.firstWhereOrNull( - (event) => event.rowId == room.state["m.room.power_levels"]?[""], - ); - - for (final userId in IMap(powerLevels?.content["users"]).keys) { - addUserId(userId); - if (servers.length >= 5) break; - } - - final members = room.state["m.room.member"]?.values.toIList(); - for (var i = 0; servers.length < 5; i++) { - final member = room.events.firstWhereOrNull( - (event) => event.rowId == members?.getOrNull(i), - ); - - if (member?.content["membership"] == "join") { - addUserId(member?.stateKey); - } - - if (members?.getOrNull(i) == null) break; - } - - return servers.isEmpty - ? "" - : "?${servers.map((server) => "via=$server").join("&")}"; - } - - static final provider = NotifierProvider.family( - ViaController.new, - ); -} diff --git a/lib/helpers/extensions/color_hex.dart b/lib/helpers/extensions/color_hex.dart new file mode 100644 index 0000000..3f04629 --- /dev/null +++ b/lib/helpers/extensions/color_hex.dart @@ -0,0 +1,8 @@ +import "package:flutter/widgets.dart"; + +extension ColorHex on Color { + String get hex { + final rgb = toARGB32() & 0x00FFFFFF; + return "#${rgb.toRadixString(16).padLeft(6, "0")}"; + } +} diff --git a/lib/helpers/extensions/event_to_message.dart b/lib/helpers/extensions/event_to_message.dart new file mode 100644 index 0000000..c003180 --- /dev/null +++ b/lib/helpers/extensions/event_to_message.dart @@ -0,0 +1,134 @@ +import "package:collection/collection.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:matrix/matrix.dart"; + +extension EventToMessage on Event { + Future toMessage({ + bool mustBeText = false, + bool includeEdits = false, + }) async { + final replyId = inReplyToEventId(); + + final newEvent = (unsigned?["m.relations"] as Map?)?["m.replace"]; + final event = newEvent == null ? this : Event.fromJson(newEvent, room); + + final replyEvent = replyId == null + ? null + : await room.getEventById(replyId); + + final sender = + await event.fetchSenderUser() ?? event.senderFromMemoryOrFallback; + final newContent = event.content["m.new_content"] as Map?; + final metadata = { + "formatted": + newContent?["formatted_body"] ?? + newContent?["body"] ?? + event.content["formatted_body"] ?? + event.content["body"] ?? + "", + "reply": await replyEvent?.toMessage(mustBeText: true), + "body": newContent?["body"] ?? event.content["body"], + "eventType": event.type, + "avatarUrl": sender.avatarUrl.toString(), + "displayName": sender.displayName ?? sender.id, + "txnId": transactionId, + }; + + final editedAt = event.relationshipType == RelationshipTypes.edit + ? event.originServerTs + : null; + + if ((redacted && !mustBeText) || + (!includeEdits && (relationshipType == RelationshipTypes.edit))) { + return null; + } + + // TODO: Use server-generated preview if enabled when https://github.com/famedly/matrix-dart-sdk/issues/2195 is fixed. + + // final match = Uri.tryParse( + // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", + // ); + + // final preview = match == null + // ? null + // : await room.client.getUrlPreview(match); + + final asText = + Message.text( + metadata: metadata, + id: eventId, + authorId: senderId, + text: redacted ? "This message has been deleted..." : event.body, + replyToMessageId: replyId, + deliveredAt: originServerTs, + editedAt: editedAt, + ) + as TextMessage; + + if (mustBeText) return asText; + + return switch (type) { + EventTypes.Encrypted => asText.copyWith( + text: "Unable to decrypt message.", + metadata: {...metadata, "formatted": "Unable to decrypt message."}, + ), + (EventTypes.Sticker || EventTypes.Message) => switch (messageType) { + (MessageTypes.Sticker || MessageTypes.Image) => Message.image( + metadata: metadata, + id: eventId, + authorId: senderId, + text: event.text, + source: (await getAttachmentUri()).toString(), + replyToMessageId: replyId, + deliveredAt: originServerTs, + ), + MessageTypes.Audio => Message.audio( + metadata: metadata, + id: eventId, + authorId: senderId, + text: event.text, + replyToMessageId: replyId, + source: (await event.getAttachmentUri()).toString(), + deliveredAt: originServerTs, + // TODO: See if we can figure out duration + duration: Duration(hours: 1), + ), + MessageTypes.File => Message.file( + name: event.content["filename"].toString(), + metadata: metadata, + id: eventId, + authorId: senderId, + source: (await event.getAttachmentUri()).toString(), + replyToMessageId: replyId, + deliveredAt: originServerTs, + ), + _ => asText, + }, + EventTypes.RoomMember => Message.system( + metadata: metadata, + id: eventId, + authorId: senderId, + text: + "${event.asUser.displayName ?? event.asUser.id} ${switch (Membership.values.firstWhereOrNull((membership) => membership.name == event.content["membership"])) { + Membership.invite => "was invited to", + Membership.join => "joined", + Membership.leave => "left", + Membership.knock => "asked to join", + Membership.ban => "was banned from", + _ => "did something relating to", + }} the room.", + ), + EventTypes.Redaction => null, + _ => + kDebugMode + ? Message.unsupported( + metadata: metadata, + id: eventId, + authorId: senderId, + replyToMessageId: replyId, + ) + : null, + }; + } +} diff --git a/lib/helpers/extensions/get_full_room.dart b/lib/helpers/extensions/get_full_room.dart new file mode 100644 index 0000000..bbd0bc5 --- /dev/null +++ b/lib/helpers/extensions/get_full_room.dart @@ -0,0 +1,13 @@ +import "package:matrix/matrix.dart"; +import "package:nexus/models/full_room.dart"; + +extension GetFullRoom on Room { + Future get fullRoom async { + await loadHeroUsers(); + return FullRoom( + roomData: this, + title: getLocalizedDisplayname(), + avatar: await avatar?.getThumbnailUri(client, width: 24, height: 24), + ); + } +} diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart index e1bb5f3..b8b1fde 100644 --- a/lib/helpers/extensions/get_headers.dart +++ b/lib/helpers/extensions/get_headers.dart @@ -1,7 +1,5 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/header_controller.dart"; +import "package:matrix/matrix.dart"; -extension GetHeaders on WidgetRef { - Map get headers => - watch(HeaderController.provider).requireValue; +extension GetHeaders on Client { + Map get headers => {"authorization": "Bearer $accessToken"}; } diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart deleted file mode 100644 index 445351f..0000000 --- a/lib/helpers/extensions/get_localpart.dart +++ /dev/null @@ -1,3 +0,0 @@ -extension GetLocalpart on String { - String get localpart => substring(1).split(":").first; -} diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart deleted file mode 100644 index 88cfd5a..0000000 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ /dev/null @@ -1,36 +0,0 @@ -import "dart:convert"; -import "dart:ffi"; -import "dart:typed_data"; -import "package:ffi/ffi.dart"; -import "package:nexus/src/third_party/gomuks.g.dart"; - -extension GomuksOwnedBufferToX on GomuksOwnedBuffer { - Uint8List toBytes() { - try { - if (base == nullptr || length <= 0) return Uint8List(0); - return Uint8List.fromList(base.asTypedList(length)); - } finally { - calloc.free(base); - } - } - - dynamic toJson() => jsonDecode(utf8.decode(toBytes())); -} - -extension JsonToGomuksBuffer on Map { - Pointer toGomuksBufferPtr() { - final jsonString = json.encode(this); - final bytes = utf8.encode(jsonString); - - final dataPtr = calloc(bytes.length); - dataPtr.asTypedList(bytes.length).setAll(0, bytes); - - final ptr = calloc(); - - ptr.ref - ..base = dataPtr - ..length = bytes.length; - - return ptr; - } -} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart deleted file mode 100644 index f4868d3..0000000 --- a/lib/helpers/extensions/link_to_mention.dart +++ /dev/null @@ -1,45 +0,0 @@ -extension LinkToMention on String { - /// Extracts a Matrix identifier from this string. - /// - /// Supports: - /// - https://matrix.to/#/... - /// - matrix:roomid/... - /// - matrix:r/... - /// - matrix:u/... - /// - /// Returns the decoded identifier (e.g. "#room:matrix.org") - /// or null if this is not a Matrix link. - String? get mention { - final trimmed = trim(); - - final matrixTo = RegExp( - r"^https?://matrix\.to/#/(.[^/?#]+)", - caseSensitive: false, - ); - - final matrixToMatch = matrixTo.firstMatch(trimmed); - if (matrixToMatch != null) { - return Uri.decodeComponent(matrixToMatch.group(1)!); - } - - if (trimmed.toLowerCase().startsWith("matrix:")) { - try { - final uri = Uri.parse(trimmed); - - if (uri.pathSegments.isNotEmpty) { - final identifier = uri.pathSegments.last; - if (identifier.isNotEmpty) { - return "${switch (uri.pathSegments.firstOrNull) { - "r" => "#", - "roomid" => "!", - "u" => "@", - _ => "", - }}${Uri.decodeComponent(identifier)}"; - } - } - } catch (_) {} - } - - return null; - } -} diff --git a/lib/helpers/extensions/list_to_messages.dart b/lib/helpers/extensions/list_to_messages.dart new file mode 100644 index 0000000..c14618b --- /dev/null +++ b/lib/helpers/extensions/list_to_messages.dart @@ -0,0 +1,9 @@ +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/helpers/extensions/event_to_message.dart"; + +extension ListToMessages on List { + Future> toMessages(Room room) async => (await Future.wait( + map((event) => Event.fromMatrixEvent(event, room).toMessage()), + )).nonNulls.toList().reversed.toList(); +} diff --git a/lib/helpers/extensions/mxc_to_https.dart b/lib/helpers/extensions/mxc_to_https.dart deleted file mode 100644 index 910f87d..0000000 --- a/lib/helpers/extensions/mxc_to_https.dart +++ /dev/null @@ -1,5 +0,0 @@ -extension MxcToHttps on Uri { - Uri mxcToHttps(String homeserver) => Uri.parse( - homeserver, - ).resolve("_matrix/client/v1/media/download/$host$path"); -} diff --git a/lib/helpers/extensions/room_to_children.dart b/lib/helpers/extensions/room_to_children.dart new file mode 100644 index 0000000..afdc99e --- /dev/null +++ b/lib/helpers/extensions/room_to_children.dart @@ -0,0 +1,27 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/helpers/extensions/get_full_room.dart"; +import "package:nexus/models/full_room.dart"; + +extension RoomToChildren on Room { + Future> getAllChildren(Client client) async { + final direct = await Future.wait( + spaceChildren + .map( + (child) => client.rooms + .firstWhereOrNull((r) => r.id == child.roomId) + ?.fullRoom, + ) + .nonNulls, + ); + + return (await Future.wait( + direct.map( + (child) async => child.roomData.isSpace + ? await child.roomData.getAllChildren(client) + : [child], + ), + )).expand((list) => list).toIList(); + } +} diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index df68a05..e238cf9 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -7,15 +7,6 @@ extension SchemeToTheme on ColorScheme { titleSpacing: 0, backgroundColor: surfaceContainerLow, ), - menuTheme: MenuThemeData( - style: MenuStyle( - backgroundColor: WidgetStatePropertyAll(primaryContainer), - ), - ), - textTheme: ThemeData( - fontFamilyFallback: ["sans", "emoji"], - brightness: brightness, - ).textTheme, inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder(), ), diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart index 7d8cab6..f4762c3 100644 --- a/lib/helpers/extensions/show_context_menu.dart +++ b/lib/helpers/extensions/show_context_menu.dart @@ -9,7 +9,6 @@ extension ShowContextMenu on BuildContext { showMenu( context: this, - constraints: BoxConstraints.loose(Size.infinite), position: RelativeRect.fromLTRB( globalPosition.dx, globalPosition.dy, diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart deleted file mode 100644 index 1698879..0000000 --- a/lib/helpers/extensions/show_user_popover.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:flutter/material.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/widgets/chat_page/user_popover.dart"; - -extension ShowUserPopover on BuildContext { - void showUserPopover(Membership member, {required Offset globalPosition}) => - showContextMenu( - globalPosition: globalPosition, - children: [ - PopupMenuItem( - enabled: false, - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: IconTheme(data: IconThemeData(), child: UserPopover(member)), - ), - ], - ); -} diff --git a/lib/main.dart b/lib/main.dart index 846f075..8cf4365 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,22 +1,19 @@ -import "dart:io"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/header_controller.dart"; -import "package:nexus/controllers/multi_provider_controller.dart"; import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/pages/chat_page.dart"; import "package:nexus/pages/login_page.dart"; -import "package:nexus/pages/verify_page.dart"; +import "package:nexus/pages/settings_page.dart"; +import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:window_manager/window_manager.dart"; import "package:flutter/material.dart"; import "package:dynamic_system_colors/dynamic_system_colors.dart"; +import "package:window_size/window_size.dart"; final GlobalKey navigatorKey = GlobalKey(); @@ -36,9 +33,7 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue} void showError(Object error, [StackTrace? stackTrace]) { if (error.toString().contains("DioException")) return; - if (error.toString().contains("Invalid source")) return; if (error.toString().contains("UTF-16")) return; - if (error.toString().contains("HTTP request failed")) return; if (error.toString().contains("Invalid image data")) return; debugPrintStack(stackTrace: stackTrace, label: error.toString()); @@ -57,17 +52,16 @@ void showError(Object error, [StackTrace? stackTrace]) { void main() async { WidgetsFlutterBinding.ensureInitialized(); - if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { - await windowManager.ensureInitialized(); - await windowManager.waitUntilReadyToShow( - WindowOptions(titleBarStyle: TitleBarStyle.hidden), - ); - await windowManager.setMinimumSize(Size.square(500)); - } + await windowManager.ensureInitialized(); + await windowManager.waitUntilReadyToShow( + WindowOptions(titleBarStyle: TitleBarStyle.hidden), + ); FlutterError.onError = (FlutterErrorDetails details) => showError(details.exception.toString(), details.stack); + setWindowMinSize(const Size.square(500)); + runApp( ProviderScope( observers: [ @@ -80,11 +74,11 @@ void main() async { ); } -class App extends StatelessWidget { +class App extends ConsumerWidget { const App({super.key}); @override - Widget build(BuildContext context) => DynamicColorBuilder( + Widget build(BuildContext context, WidgetRef ref) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => MaterialApp( navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, @@ -98,40 +92,42 @@ class App extends StatelessWidget { brightness: Brightness.dark, )) .theme, - home: Scaffold( - body: Consumer( - builder: (_, ref, _) => ref - .watch( - MultiProviderController.provider( - IListConst([ - SharedPrefsController.provider, - ClientController.provider, - HeaderController.provider, - ]), - ), - ) - .betterWhen( - data: (_) => Consumer( - builder: (_, ref, _) { - final clientState = ref.watch( - ClientStateController.provider, - ); - - if (clientState == null || !clientState.isInitialized) { - return Loading(); - } - - if (!clientState.isLoggedIn) { - return LoginPage(); - } else if (!clientState.isVerified) { - return VerifyPage(); - } else { - return ChatPage(); - } - }, - ), - ), - ), + home: Builder( + builder: (context) => ref + .watch(SharedPrefsController.provider) + .betterWhen( + data: (_) => ref + .watch(ClientController.provider) + .betterWhen( + data: (client) => + client.accessToken == null ? LoginPage() : ChatPage(), + loading: () => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Text( + "Syncing...", + style: Theme.of(context).textTheme.headlineMedium, + ), + Loading(), + ], + ), + ), + appBar: Appbar( + actions: [ + IconButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.settings), + ), + ], + ), + ), + ), + ), ), ), ); diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart deleted file mode 100644 index a325ffe..0000000 --- a/lib/models/account_data.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "account_data.freezed.dart"; -part "account_data.g.dart"; - -@freezed -abstract class AccountData with _$AccountData { - const factory AccountData({ - required String userId, - required String? roomId, - required String type, - required dynamic content, - }) = _AccountData; - - factory AccountData.fromJson(Map json) => - _$AccountDataFromJson(json); -} diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart deleted file mode 100644 index 1e15136..0000000 --- a/lib/models/client_state.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "client_state.freezed.dart"; -part "client_state.g.dart"; - -@freezed -abstract class ClientState with _$ClientState { - const factory ClientState({ - required bool isInitialized, - required bool isLoggedIn, - required bool isVerified, - required String? userId, - required String? homeserverUrl, - }) = _ClientState; - - factory ClientState.fromJson(Map json) => - _$ClientStateFromJson(json); -} diff --git a/lib/models/configs/message_config.dart b/lib/models/configs/message_config.dart deleted file mode 100644 index 66a437c..0000000 --- a/lib/models/configs/message_config.dart +++ /dev/null @@ -1,28 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "message_config.freezed.dart"; -part "message_config.g.dart"; - -@freezed -abstract class MessageConfig with _$MessageConfig { - const MessageConfig._(); - const factory MessageConfig({ - @Default(false) bool alwaysReturn, - @Default(false) bool includeEdits, - required Room room, - required Event event, - }) = _MessageConfig; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is MessageConfig && - other.event == event; - - @override - int get hashCode => Object.hash(runtimeType, event); - - factory MessageConfig.fromJson(Map json) => - _$MessageConfigFromJson(json); -} diff --git a/lib/models/configs/messages_config.dart b/lib/models/configs/messages_config.dart deleted file mode 100644 index b33a71c..0000000 --- a/lib/models/configs/messages_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/room.dart"; -part "messages_config.freezed.dart"; -part "messages_config.g.dart"; - -@freezed -abstract class MessagesConfig with _$MessagesConfig { - const factory MessagesConfig({ - required Room room, - required IList events, - }) = _MessagesConfig; - - factory MessagesConfig.fromJson(Map json) => - _$MessagesConfigFromJson(json); -} diff --git a/lib/models/configs/power_level_config.dart b/lib/models/configs/power_level_config.dart deleted file mode 100644 index 31cc08c..0000000 --- a/lib/models/configs/power_level_config.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/requests/membership_action.dart"; -part "power_level_config.freezed.dart"; -part "power_level_config.g.dart"; - -@freezed -abstract class PowerLevelConfig with _$PowerLevelConfig { - const factory PowerLevelConfig({ - @Default(false) bool isStateEvent, - required String eventType, - MembershipAction? action, - String? targetUser, - }) = _PowerLevelConfig; - - factory PowerLevelConfig.fromJson(Map json) => - _$PowerLevelConfigFromJson(json); -} diff --git a/lib/models/epoch_date_time_converter.dart b/lib/models/epoch_date_time_converter.dart deleted file mode 100644 index c26d020..0000000 --- a/lib/models/epoch_date_time_converter.dart +++ /dev/null @@ -1,11 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -class EpochDateTimeConverter implements JsonConverter { - const EpochDateTimeConverter(); - - @override - DateTime fromJson(int json) => DateTime.fromMillisecondsSinceEpoch(json); - - @override - int toJson(DateTime object) => object.millisecondsSinceEpoch; -} diff --git a/lib/models/event.dart b/lib/models/event.dart deleted file mode 100644 index 734f667..0000000 --- a/lib/models/event.dart +++ /dev/null @@ -1,80 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/epoch_date_time_converter.dart"; -part "event.freezed.dart"; -part "event.g.dart"; - -@freezed -abstract class Event with _$Event { - const factory Event({ - @JsonKey(name: "rowid") required int rowId, - @JsonKey(name: "timeline_rowid") required int timelineRowId, - required String roomId, - required String eventId, - @JsonKey(name: "sender") required String authorId, - required String type, - String? stateKey, - @EpochDateTimeConverter() required DateTime timestamp, - required IMap content, - IMap? decrypted, - String? decryptedType, - @Default(IMap.empty()) IMap unsigned, - LocalContent? localContent, - String? transactionId, - String? redactedBy, - String? relatesTo, - String? relationType, - String? decryptionError, - String? sendError, - @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, - @UnreadTypeConverter() UnreadType? unreadType, - }) = _Event; - - factory Event.fromJson(Map json) => _$EventFromJson(json); -} - -@freezed -abstract class LocalContent with _$LocalContent { - const factory LocalContent({ - String? sanitizedHtml, - String? editSource, - bool? wasPlaintext, - bool? bigEmoji, - bool? hasMath, - bool? replyFallbackRemoved, - }) = _LocalContent; - - factory LocalContent.fromJson(Map json) => - _$LocalContentFromJson(json); -} - -class UnreadTypeConverter implements JsonConverter { - const UnreadTypeConverter(); - - @override - UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json); - - @override - int? toJson(UnreadType? object) => object?.value; -} - -// I think this is correct but I'm not sure, its some type of bitmask. -@immutable -class UnreadType { - final int value; - - const UnreadType(this.value); - - static const none = UnreadType(0); - static const normal = UnreadType(1); - static const notify = UnreadType(2); - static const highlight = UnreadType(4); - static const sound = UnreadType(8); - - bool get isNone => value == 0; - bool get isNormal => (value & 1) != 0; - bool get shouldNotify => (value & 2) != 0; - bool get isHighlighted => (value & 4) != 0; - bool get playsSound => (value & 8) != 0; -} diff --git a/lib/models/full_room.dart b/lib/models/full_room.dart new file mode 100644 index 0000000..ee61da6 --- /dev/null +++ b/lib/models/full_room.dart @@ -0,0 +1,13 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:matrix/matrix.dart"; +part "full_room.freezed.dart"; + +@freezed +abstract class FullRoom with _$FullRoom { + const FullRoom._(); + const factory FullRoom({ + required Room roomData, + required String title, + required Uri? avatar, + }) = _FullRoom; +} diff --git a/lib/models/image_data.dart b/lib/models/image_data.dart new file mode 100644 index 0000000..e5bc57e --- /dev/null +++ b/lib/models/image_data.dart @@ -0,0 +1,11 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "image_data.freezed.dart"; + +@freezed +abstract class ImageData with _$ImageData { + const factory ImageData({ + required String uri, + required int? height, + required int? width, + }) = _ImageData; +} diff --git a/lib/models/lazy_load_summary.dart b/lib/models/lazy_load_summary.dart deleted file mode 100644 index 0cd250f..0000000 --- a/lib/models/lazy_load_summary.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "lazy_load_summary.freezed.dart"; -part "lazy_load_summary.g.dart"; - -@freezed -abstract class LazyLoadSummary with _$LazyLoadSummary { - const factory LazyLoadSummary({ - required IList? heroes, - required int? joinedMemberCount, - required int? invitedMemberCount, - }) = _LazyLoadSummary; - - factory LazyLoadSummary.fromJson(Map json) => - _$LazyLoadSummaryFromJson(json); -} diff --git a/lib/models/membership.dart b/lib/models/membership.dart deleted file mode 100644 index ce0cc42..0000000 --- a/lib/models/membership.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/membership_status.dart"; -part "membership.freezed.dart"; - -@freezed -abstract class Membership with _$Membership { - const Membership._(); - const factory Membership({ - required MembershipStatus status, - required Uri? avatarUrl, - required String displayName, - required String userId, - }) = _Membership; - - factory Membership.fromContent( - IMap content, - String userId, - String homeserver, - ) => Membership( - status: MembershipStatus.values.firstWhere( - (status) => status.name == content["membership"], - orElse: () => MembershipStatus.leave, - ), - avatarUrl: Uri.tryParse( - content["avatar_url"] ?? "", - )?.mxcToHttps(homeserver), - userId: userId, - displayName: content["displayname"] ?? userId.substring(1).split(":").first, - ); -} diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart deleted file mode 100644 index bc85e22..0000000 --- a/lib/models/membership_status.dart +++ /dev/null @@ -1,4 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() -enum MembershipStatus { leave, invite, ban, join } diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart deleted file mode 100644 index df0a0f6..0000000 --- a/lib/models/paginate.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -part "paginate.freezed.dart"; -part "paginate.g.dart"; - -@freezed -abstract class Paginate with _$Paginate { - const factory Paginate({ - required IList events, - required IList relatedEvents, - required bool hasMore, - }) = _Paginate; - - factory Paginate.fromJson(Map json) => - _$PaginateFromJson(json); -} diff --git a/lib/models/profile.dart b/lib/models/profile.dart deleted file mode 100644 index 584f27b..0000000 --- a/lib/models/profile.dart +++ /dev/null @@ -1,36 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "profile.freezed.dart"; -part "profile.g.dart"; - -Object? readPronouns(Map map, _) => - map["m.pronouns"] ?? map["io.fsky.nyx.pronouns"]; - -Object? readTimezone(Map map, _) => - map["m.tz"] ?? map["us.cloke.msc4175.tz"]; - -@freezed -abstract class Profile with _$Profile { - const factory Profile({ - String? avatarUrl, - @JsonKey(name: "displayname") String? displayName, - - @JsonKey(readValue: readTimezone) String? timezone, - - @Default(IList.empty()) - @JsonKey(readValue: readPronouns) - IList pronouns, - }) = _Profile; - - factory Profile.fromJson(Map json) => - _$ProfileFromJson(json); -} - -@freezed -abstract class Pronoun with _$Pronoun { - const factory Pronoun({required String language, required String summary}) = - _Pronoun; - - factory Pronoun.fromJson(Map json) => - _$PronounFromJson(json); -} diff --git a/lib/models/read_receipt.dart b/lib/models/read_receipt.dart deleted file mode 100644 index d533e2d..0000000 --- a/lib/models/read_receipt.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/epoch_date_time_converter.dart"; -part "read_receipt.freezed.dart"; -part "read_receipt.g.dart"; - -@freezed -abstract class ReadReceipt with _$ReadReceipt { - const factory ReadReceipt({ - String? roomId, - required String userId, - String? threadId, - required String eventId, - @EpochDateTimeConverter() required DateTime timestamp, - }) = _ReadReceipt; - - factory ReadReceipt.fromJson(Map json) => - _$ReadReceiptFromJson(json); -} diff --git a/lib/models/requests/get_event_request.dart b/lib/models/requests/get_event_request.dart deleted file mode 100644 index 9374f3a..0000000 --- a/lib/models/requests/get_event_request.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; -part "get_event_request.freezed.dart"; -part "get_event_request.g.dart"; - -@Freezed(toJson: false) -abstract class GetEventRequest with _$GetEventRequest { - const GetEventRequest._(); - const factory GetEventRequest({ - required Room room, - required String eventId, - @Default(false) bool unredact, - }) = _GetEventRequest; - - Map toJson() => { - "room_id": room.metadata?.id, - "event_id": eventId, - "unredact": unredact, - }; - - @override - bool operator ==(Object other) => - other.runtimeType == runtimeType && - other is GetEventRequest && - other.eventId == eventId; - - @override - int get hashCode => Object.hash(runtimeType, eventId); - - factory GetEventRequest.fromJson(Map json) => - _$GetEventRequestFromJson(json); -} diff --git a/lib/models/requests/get_related_events_request.dart b/lib/models/requests/get_related_events_request.dart deleted file mode 100644 index 7e2244f..0000000 --- a/lib/models/requests/get_related_events_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "get_related_events_request.freezed.dart"; -part "get_related_events_request.g.dart"; - -@freezed -abstract class GetRelatedEventsRequest with _$GetRelatedEventsRequest { - const factory GetRelatedEventsRequest({ - required String roomId, - required String eventId, - required String relationType, - }) = _GetRelatedEventsRequest; - - factory GetRelatedEventsRequest.fromJson(Map json) => - _$GetRelatedEventsRequestFromJson(json); -} diff --git a/lib/models/requests/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart deleted file mode 100644 index 8ee05f0..0000000 --- a/lib/models/requests/get_room_state_request.dart +++ /dev/null @@ -1,16 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "get_room_state_request.freezed.dart"; -part "get_room_state_request.g.dart"; - -@freezed -abstract class GetRoomStateRequest with _$GetRoomStateRequest { - const factory GetRoomStateRequest({ - required String roomId, - @Default(false) bool refetch, - @Default(false) bool fetchMembers, - @Default(false) bool includeMembers, - }) = _GetRoomStateRequest; - - factory GetRoomStateRequest.fromJson(Map json) => - _$GetRoomStateRequestFromJson(json); -} diff --git a/lib/models/requests/join_room_request.dart b/lib/models/requests/join_room_request.dart deleted file mode 100644 index d6b411e..0000000 --- a/lib/models/requests/join_room_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "join_room_request.freezed.dart"; -part "join_room_request.g.dart"; - -@freezed -abstract class JoinRoomRequest with _$JoinRoomRequest { - const factory JoinRoomRequest({ - required String roomIdOrAlias, - required IList via, - }) = _JoinRoomRequest; - - factory JoinRoomRequest.fromJson(Map json) => - _$JoinRoomRequestFromJson(json); -} diff --git a/lib/models/requests/login_request.dart b/lib/models/requests/login_request.dart deleted file mode 100644 index b3704fa..0000000 --- a/lib/models/requests/login_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "login_request.freezed.dart"; -part "login_request.g.dart"; - -@freezed -abstract class LoginRequest with _$LoginRequest { - const factory LoginRequest({ - required String username, - required String password, - required String homeserverUrl, - }) = _LoginRequest; - - factory LoginRequest.fromJson(Map json) => - _$LoginRequestFromJson(json); -} diff --git a/lib/models/requests/membership_action.dart b/lib/models/requests/membership_action.dart deleted file mode 100644 index d852164..0000000 --- a/lib/models/requests/membership_action.dart +++ /dev/null @@ -1,4 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -@JsonEnum() -enum MembershipAction { ban, kick, unban, invite } diff --git a/lib/models/requests/paginate_request.dart b/lib/models/requests/paginate_request.dart deleted file mode 100644 index 44cf8ec..0000000 --- a/lib/models/requests/paginate_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "paginate_request.freezed.dart"; -part "paginate_request.g.dart"; - -@freezed -abstract class PaginateRequest with _$PaginateRequest { - const factory PaginateRequest({ - required String roomId, - required int? maxTimelineId, - @Default(20) int limit, - }) = _PaginateRequest; - - factory PaginateRequest.fromJson(Map json) => - _$PaginateRequestFromJson(json); -} diff --git a/lib/models/requests/redact_event_request.dart b/lib/models/requests/redact_event_request.dart deleted file mode 100644 index fed2255..0000000 --- a/lib/models/requests/redact_event_request.dart +++ /dev/null @@ -1,3 +0,0 @@ -import "package:nexus/models/requests/report_request.dart"; - -typedef RedactEventRequest = ReportRequest; diff --git a/lib/models/requests/report_request.dart b/lib/models/requests/report_request.dart deleted file mode 100644 index 749ad60..0000000 --- a/lib/models/requests/report_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "report_request.freezed.dart"; -part "report_request.g.dart"; - -@freezed -abstract class ReportRequest with _$ReportRequest { - const factory ReportRequest({ - required String roomId, - required String eventId, - String? reason, - }) = _ReportRequest; - - factory ReportRequest.fromJson(Map json) => - _$ReportRequestFromJson(json); -} diff --git a/lib/models/requests/send_event_request.dart b/lib/models/requests/send_event_request.dart deleted file mode 100644 index da5de32..0000000 --- a/lib/models/requests/send_event_request.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "send_event_request.freezed.dart"; -part "send_event_request.g.dart"; - -@freezed -abstract class SendEventRequest with _$SendEventRequest { - const factory SendEventRequest({ - required String roomId, - required String type, - required Map content, - @Default(false) bool synchronous, - @Default(false) bool disableEncryption, - }) = _SendEventRequest; - - factory SendEventRequest.fromJson(Map json) => - _$SendEventRequestFromJson(json); -} diff --git a/lib/models/requests/send_message_request.dart b/lib/models/requests/send_message_request.dart deleted file mode 100644 index 883c585..0000000 --- a/lib/models/requests/send_message_request.dart +++ /dev/null @@ -1,54 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/relation_type.dart"; -part "send_message_request.freezed.dart"; -part "send_message_request.g.dart"; - -@freezed -abstract class SendMessageRequest with _$SendMessageRequest { - const factory SendMessageRequest({ - required String roomId, - required String text, - @Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions, - @JsonKey(name: "relates_to") Relation? relation, - }) = _SendMessageRequest; - - factory SendMessageRequest.fromJson(Map json) => - _$SendMessageRequestFromJson(json); -} - -@freezed -abstract class Mentions with _$Mentions { - const factory Mentions({ - @Default(false) bool room, - @Default(IList.empty()) IList userIds, - }) = _Mentions; - - factory Mentions.fromJson(Map json) => - _$MentionsFromJson(json); -} - -@Freezed(toJson: false) -abstract class Relation with _$Relation { - const Relation._(); - - const factory Relation({ - required String eventId, - required RelationType relationType, - }) = _Relation; - - Map toJson() { - switch (relationType) { - case RelationType.reply: - return { - "m.in_reply_to": {"event_id": eventId}, - }; - - case RelationType.edit: - return {"rel_type": "m.replace", "event_id": eventId}; - } - } - - factory Relation.fromJson(Map json) => - _$RelationFromJson(json); -} diff --git a/lib/models/requests/set_membership_request.dart b/lib/models/requests/set_membership_request.dart deleted file mode 100644 index dd0e1f2..0000000 --- a/lib/models/requests/set_membership_request.dart +++ /dev/null @@ -1,19 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/requests/membership_action.dart"; -part "set_membership_request.freezed.dart"; -part "set_membership_request.g.dart"; - -@freezed -abstract class SetMembershipRequest with _$SetMembershipRequest { - const factory SetMembershipRequest({ - required String userId, - required String roomId, - - String? reason, - @JsonKey(name: "action") required MembershipAction action, - @Default(false) @JsonKey(name: "msc4293_redact_events") bool redact, - }) = _SetMembershipRequest; - - factory SetMembershipRequest.fromJson(Map json) => - _$SetMembershipRequestFromJson(json); -} diff --git a/lib/models/room.dart b/lib/models/room.dart deleted file mode 100644 index 3c3eec0..0000000 --- a/lib/models/room.dart +++ /dev/null @@ -1,36 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/event.dart"; -import "package:nexus/models/read_receipt.dart"; -import "package:nexus/models/room_metadata.dart"; -part "room.freezed.dart"; -part "room.g.dart"; - -@freezed -abstract class Room with _$Room { - const factory Room({ - @JsonKey(name: "meta") RoomMetadata? metadata, - @Default(IList.empty()) IList timeline, - @Default(false) bool reset, - @Default(IMap.empty()) IMap> state, - // required IMap accountData, - @Default(IList.empty()) IList events, - @Default(IMap.empty()) IMap> receipts, - @Default(false) bool dismissNotifications, - @Default(true) bool hasMore, - // required IList notifications, - }) = _Room; - - factory Room.fromJson(Map json) => _$RoomFromJson(json); -} - -@freezed -abstract class TimelineRowTuple with _$TimelineRowTuple { - const factory TimelineRowTuple({ - @JsonKey(name: "timeline_rowid") required int timelineRowId, - @JsonKey(name: "event_rowid") int? eventRowId, - }) = _TimelineRowTuple; - - factory TimelineRowTuple.fromJson(Map json) => - _$TimelineRowTupleFromJson(json); -} diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart deleted file mode 100644 index 7c16cae..0000000 --- a/lib/models/room_metadata.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/epoch_date_time_converter.dart"; -import "package:nexus/models/lazy_load_summary.dart"; -part "room_metadata.freezed.dart"; -part "room_metadata.g.dart"; - -@freezed -abstract class RoomMetadata with _$RoomMetadata { - const factory RoomMetadata({ - @JsonKey(name: "room_id") required String id, - - // required CreateEventContent creationContent, - // required TombstoneEventContent tombstoneEventContent, - String? name, - Uri? avatar, - String? dmUserId, - String? topic, - String? canonicalAlias, - LazyLoadSummary? lazyLoadSummary, - required bool hasMemberList, - @JsonKey(name: "preview_event_rowid") required int previewEventRowID, - @EpochDateTimeConverter() required DateTime sortingTimestamp, - required int unreadHighlights, - required int unreadNotifications, - required int unreadMessages, - }) = _RoomMetadata; - - factory RoomMetadata.fromJson(Map json) => - _$RoomMetadataFromJson(json); -} diff --git a/lib/models/session_backup.dart b/lib/models/session_backup.dart new file mode 100644 index 0000000..0245c7e --- /dev/null +++ b/lib/models/session_backup.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +part "session_backup.freezed.dart"; +part "session_backup.g.dart"; + +@freezed +abstract class SessionBackup with _$SessionBackup { + const factory SessionBackup({ + required String accessToken, + required Uri homeserver, + required String userID, + required String deviceID, + required String deviceName, + }) = _SessionBackup; + + factory SessionBackup.fromJson(Map json) => + _$SessionBackupFromJson(json); +} diff --git a/lib/models/space.dart b/lib/models/space.dart index 631759a..d64dcc9 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -1,16 +1,20 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/widgets.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/models/full_room.dart"; part "space.freezed.dart"; @freezed abstract class Space with _$Space { + const Space._(); const factory Space({ - required String id, required String title, + required String id, + required IList children, + required Client client, + Room? roomData, + Uri? avatar, IconData? icon, - Room? room, - required IList children, }) = _Space; } diff --git a/lib/models/space_edge.dart b/lib/models/space_edge.dart deleted file mode 100644 index 192af31..0000000 --- a/lib/models/space_edge.dart +++ /dev/null @@ -1,14 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "space_edge.freezed.dart"; -part "space_edge.g.dart"; - -@freezed -abstract class SpaceEdge with _$SpaceEdge { - const factory SpaceEdge({ - required String childId, - @Default(false) bool suggested, - }) = _SpaceEdge; - - factory SpaceEdge.fromJson(Map json) => - _$SpaceEdgeFromJson(json); -} diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart deleted file mode 100644 index 0f98bb2..0000000 --- a/lib/models/sync_data.dart +++ /dev/null @@ -1,23 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/account_data.dart"; -import "package:nexus/models/room.dart"; -import "package:nexus/models/space_edge.dart"; -part "sync_data.freezed.dart"; -part "sync_data.g.dart"; - -@freezed -abstract class SyncData with _$SyncData { - const factory SyncData({ - @Default(false) bool clearState, - @Default(IMap.empty()) IMap accountData, - @Default(IMap.empty()) IMap rooms, - @Default(ISet.empty()) ISet leftRooms, - // required IList invitedRooms, - IMap>? spaceEdges, - IList? topLevelSpaces, - }) = _SyncData; - - factory SyncData.fromJson(Map json) => - _$SyncDataFromJson(json); -} diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart deleted file mode 100644 index 7848fbe..0000000 --- a/lib/models/sync_status.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -part "sync_status.freezed.dart"; -part "sync_status.g.dart"; - -@freezed -abstract class SyncStatus with _$SyncStatus { - const factory SyncStatus({ - required SyncStatusType type, - String? error, - required int errorCount, - }) = _SyncStatus; - - factory SyncStatus.fromJson(Map json) => - _$SyncStatusFromJson(json); -} - -@JsonEnum(fieldRename: FieldRename.kebab) -enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 671891c..c60cc45 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,46 +1,31 @@ import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/init_complete_controller.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/chat_page/sidebar.dart"; import "package:nexus/widgets/chat_page/room_chat.dart"; -import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/chat_page/sidebar.dart"; -class ChatPage extends ConsumerWidget { +class ChatPage extends StatelessWidget { const ChatPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( + Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; - final initComplete = ref.watch(InitCompleteController.provider); return Scaffold( - appBar: initComplete ? null : Appbar(), - body: initComplete - ? Builder( - builder: (context) => Row( - children: [ - if (isDesktop) Sidebar(isDesktop: isDesktop), - Expanded( - child: RoomChat( - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, - ), - ), - ], - ), - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [Loading(), Text("Syncing...")], + body: Builder( + builder: (context) => Row( + children: [ + if (isDesktop) Sidebar(), + Expanded( + child: RoomChat( + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, ), ), - drawer: isDesktop || !initComplete - ? null - : Sidebar(isDesktop: isDesktop), + ], + ), + ), + drawer: isDesktop ? null : Sidebar(), ); }, ); diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index fda53d0..4631325 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -5,7 +5,6 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/models/homeserver.dart"; -import "package:nexus/models/requests/login_request.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/loading.dart"; @@ -16,25 +15,27 @@ class LoginPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final client = ref.watch(ClientController.provider.notifier); final isLoading = useState(false); - final homeserver = useState(null); + final allowLogin = useState(false); final launch = ref.watch(LaunchHelper.provider).launchUrl; - Future setHomeserver(Uri? newHomeserver) async { + Future setHomeserver(Uri? homeserver) async { isLoading.value = true; + final succeeded = homeserver == null + ? false + : await ref + .watch(ClientController.provider.notifier) + .setHomeserver( + homeserver.hasScheme + ? homeserver + : Uri.https(homeserver.path), + ); - homeserver.value = newHomeserver == null - ? null - : await client.discoverHomeserver( - newHomeserver.hasScheme - ? newHomeserver - : Uri.https(newHomeserver.path), - ); - - if (homeserver.value == null && context.mounted) { + if (succeeded) { + allowLogin.value = true; + } else if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -97,7 +98,6 @@ class LoginPage extends HookConsumerWidget { ), ), IconButton.filled( - tooltip: "Confirm homeserver choice", onPressed: isLoading.value ? null : () => setHomeserver(Uri.tryParse(homeserverUrl.text)), @@ -124,11 +124,11 @@ class LoginPage extends HookConsumerWidget { iconUrl: "https://federated.nexus/images/icon.png", ), Homeserver( - name: "Unredacted", + name: "envs.net", description: - "Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.", - url: Uri.https("unredacted.org", "services/si/matrix"), - iconUrl: "https://unredacted.org/favicon.ico", + "envs.net is a minimalist, non-commercial shared linux system and will always be free to use.", + url: Uri.https("envs.net"), + iconUrl: "https://envs.net/favicon.ico", ), ].map( (homeserver) => Card( @@ -144,7 +144,6 @@ class LoginPage extends HookConsumerWidget { ? null : () => setHomeserver(homeserver.url), trailing: IconButton( - tooltip: "Launch homeserver info page", onPressed: () => launch(homeserver.url), icon: Icon(Icons.info_outline), ), @@ -158,7 +157,7 @@ class LoginPage extends HookConsumerWidget { ), if (isLoading.value) Padding(padding: EdgeInsets.only(top: 32), child: Loading()) - else if (homeserver.value != null) ...[ + else if (allowLogin.value) ...[ DividerText("Then, sign in:"), SizedBox(height: 4), TextField( @@ -175,19 +174,15 @@ class LoginPage extends HookConsumerWidget { ElevatedButton( onPressed: () async { isLoading.value = true; - final error = await client.login( - LoginRequest( - username: username.text, - password: password.text, - homeserverUrl: homeserver.value!, - ), - ); + final succeeded = await ref + .watch(ClientController.provider.notifier) + .login(username.text, password.text); - if (error != null && context.mounted) { + if (!succeeded && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - "Login failed. Is your password right?\nError: $error", + "Login failed. Is your password right?", style: TextStyle( color: theme.colorScheme.onErrorContainer, ), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 505904c..b348aac 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,11 +1,18 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/secure_storage_controller.dart"; class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - return Placeholder(); + return Scaffold( + appBar: AppBar(title: Text("Settings")), + body: ElevatedButton( + onPressed: ref.watch(SecureStorageController.provider.notifier).clear, + child: Text("Log out"), + ), + ); } } diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart deleted file mode 100644 index 962701c..0000000 --- a/lib/pages/verify_page.dart +++ /dev/null @@ -1,86 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/widgets/appbar.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class VerifyPage extends HookConsumerWidget { - const VerifyPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final passphraseController = useTextEditingController(); - final isVerifying = useState(false); - return Scaffold( - appBar: Appbar(), - body: AlertDialog( - title: Text("Verify"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - autofocus: true, - capitalize: true, - controller: passphraseController, - obscure: true, - title: "Recovery Key or Passphrase", - ), - ], - ), - actions: [ - TextButton( - onPressed: isVerifying.value - ? null - : () async { - final scaffoldMessenger = ScaffoldMessenger.of(context); - final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - "Attempting to verify with recovery key...", - ), - duration: Duration(days: 999), - ), - ); - - isVerifying.value = true; - - final error = await ref - .watch(ClientController.provider.notifier) - .verify(passphraseController.text); - - snackbar.close(); - if (error != null) { - isVerifying.value = false; - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - "Verification failed. Is your passphrase correct?\nError: $error", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - }, - child: Text("Verify"), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index aae6c13..3ecaa1d 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -1,65 +1,35 @@ import "dart:io"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:window_manager/window_manager.dart"; class Appbar extends StatelessWidget implements PreferredSizeWidget { final Widget? leading; final Widget? title; final Color? backgroundColor; final double? scrolledUnderElevation; - final IList actions; - + final List actions; const Appbar({ super.key, this.title, this.backgroundColor, this.scrolledUnderElevation, this.leading, - this.actions = const IList.empty(), + this.actions = const [], }); @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); + Size get preferredSize => AppBar().preferredSize; @override - Widget build(BuildContext context) { - Future maximize() async { - final isMaximized = await windowManager.isMaximized(); - - if (isMaximized) { - return windowManager.unmaximize(); - } - - return windowManager.maximize(); - } - - return GestureDetector( - onPanStart: (_) => windowManager.startDragging(), - child: AppBar( - leading: leading, - backgroundColor: backgroundColor, - scrolledUnderElevation: scrolledUnderElevation, - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - title: IgnorePointer(child: title), - flexibleSpace: GestureDetector(onDoubleTap: maximize), - actions: [ - ...actions, - if (!(Platform.isAndroid || Platform.isIOS)) ...[ - if (!Platform.isLinux) - IconButton( - tooltip: "Maximize window", - onPressed: maximize, - icon: const Icon(Icons.fullscreen), - ), - IconButton( - tooltip: "Close window", - onPressed: () => exit(0), - icon: const Icon(Icons.close), - ), - ], - ], - ), - ); - } + AppBar build(BuildContext context) => AppBar( + leading: leading, + backgroundColor: backgroundColor, + scrolledUnderElevation: scrolledUnderElevation, + actionsPadding: EdgeInsets.symmetric(horizontal: 8), + title: title, + actions: [ + ...actions, + if (!(Platform.isAndroid || Platform.isIOS)) + IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)), + ], + ); } diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 28662e2..41bd002 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -1,32 +1,28 @@ import "package:color_hash/color_hash.dart"; -import "package:cross_cache/cross_cache.dart"; import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -class AvatarOrHash extends ConsumerWidget { +class AvatarOrHash extends StatelessWidget { final Uri? avatar; final String title; final Widget? fallback; final bool hasBadge; - final int badgeNumber; final double height; + final Map headers; const AvatarOrHash( this.avatar, this.title, { this.fallback, - this.badgeNumber = 0, this.hasBadge = false, this.height = 24, + required this.headers, super.key, }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Text(title.isEmpty ? "" : title[0])), + child: Center(child: Text(title[0])), ); return SizedBox( width: height, @@ -34,23 +30,19 @@ class AvatarOrHash extends ConsumerWidget { child: Center( child: Badge( isLabelVisible: hasBadge, - label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null, - smallSize: 12, - backgroundColor: Theme.of(context).colorScheme.primary, + smallSize: 8, + backgroundColor: Theme.of(context).colorScheme.onPrimaryContainer, child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular((height - 8) / 2.5)), + borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox( width: height, height: height, child: avatar == null ? fallback ?? box - : Image( - image: CachedNetworkImage( - avatar.toString(), - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.cover, + : Image.network( + avatar.toString(), + headers: headers, + fit: BoxFit.contain, errorBuilder: (_, _, _) => box, ), ), diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/chat_box.dart similarity index 54% rename from lib/widgets/chat_page/composer/chat_box.dart rename to lib/widgets/chat_page/chat_box.dart index 478974e..016e3a7 100644 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ b/lib/widgets/chat_page/chat_box.dart @@ -1,33 +1,26 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "dart:io"; import "package:flutter/material.dart"; +import "package:flutter/services.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/models/configs/power_level_config.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; -import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; +import "package:nexus/widgets/chat_page/mention_overlay.dart"; +import "package:nexus/widgets/chat_page/relation_preview.dart"; class ChatBox extends HookConsumerWidget { final Message? relatedMessage; final RelationType relationType; final VoidCallback onDismiss; - final FocusNode? node; - final Future Function( - String text, { - required bool shouldMention, - required IList tags, - }) - onSend; + final Room room; const ChatBox({ required this.relatedMessage, required this.relationType, required this.onDismiss, - required this.onSend, - this.node, + required this.room, super.key, }); @@ -36,38 +29,52 @@ class ChatBox extends HookConsumerWidget { final theme = Theme.of(context); final controller = useRef(FlutterTaggerController()); final triggerCharacter = useState(""); - final shouldMention = useState(true); final query = useState(""); if (relationType == RelationType.edit && relatedMessage is TextMessage && controller.value.text.isEmpty) { - controller.value.text = relatedMessage?.metadata?["editSource"] ?? ""; + final text = (relatedMessage as TextMessage).text; + controller.value.text = relatedMessage?.replyToMessageId == null + ? text + : text.split("\n\n").sublist(1).join("\n\n"); } void send() { - if (controller.value.text.isEmpty) return; - onSend( - controller.value.formattedText, - shouldMention: shouldMention.value, - tags: controller.value.tags.toIList(), - ); - + ref + .watch(RoomChatController.provider(room).notifier) + .send( + controller.value.formattedText, + relation: relatedMessage, + relationType: relationType, + tags: controller.value.tags, + ); onDismiss(); controller.value.text = ""; } + final node = useFocusNode( + onKeyEvent: (_, event) { + if (event is KeyDownEvent && !Platform.isAndroid && !Platform.isIOS) { + if (event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + send(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + )..requestFocus(); + final style = TextStyle( color: theme.colorScheme.primary, fontWeight: FontWeight.bold, ); - final canSendMessages = ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), - ), - ); - return Positioned( bottom: 0, left: 0, @@ -79,12 +86,10 @@ class ChatBox extends HookConsumerWidget { child: Column( children: [ RelationPreview( - relatedMessage, - shouldMention: shouldMention.value, - toggleShouldMention: () => - shouldMention.value = !shouldMention.value, + relatedMessage: relatedMessage, relationType: relationType, onDismiss: onDismiss, + room: room, ), Container( color: theme.colorScheme.surfaceContainerHighest, @@ -92,45 +97,21 @@ class ChatBox extends HookConsumerWidget { child: Row( spacing: 8, children: [ - EmojiPickerButton( - context: context, - onSelection: (_) => node?.requestFocus(), - controller: controller.value, - ), PopupMenuButton( - tooltip: "Add media", - enabled: canSendMessages, - itemBuilder: (context) => [ - PopupMenuItem( - child: ListTile( - title: Text("Camera"), - leading: Icon(Icons.add_a_photo), - ), - ), - PopupMenuItem( - child: ListTile( - title: Text("Gallery"), - leading: Icon(Icons.add_photo_alternate), - ), - ), - PopupMenuItem( - child: ListTile( - title: Text("Files"), - leading: Icon(Icons.attachment), - ), - ), - ], + itemBuilder: (context) => [], icon: Icon(Icons.add), + enabled: room.canSendDefaultMessages, ), Expanded( child: FlutterTagger( triggerStrategy: TriggerStrategy.eager, overlay: MentionOverlay( + room, query: query.value, triggerCharacter: triggerCharacter.value, addTag: ({required id, required name}) { controller.value.addTag(id: id, name: name); - node?.requestFocus(); + node.requestFocus(); }, ), controller: controller.value, @@ -138,32 +119,31 @@ class ChatBox extends HookConsumerWidget { triggerCharacter.value = newTriggerCharacter; query.value = newQuery; }, - triggerCharacterAndStyles: {"@": style, "#": style}, + triggerCharacterAndStyles: { + "@": style, + "#": style, + ":": style, + }, builder: (context, key) => TextFormField( - enabled: canSendMessages, + enabled: room.canSendDefaultMessages, maxLines: 12, minLines: 1, - autofocus: true, decoration: InputDecoration( - hintText: canSendMessages + hintText: room.canSendDefaultMessages ? "Your message here..." : "You don't have permission to send messages in this room...", border: InputBorder.none, ), controller: controller.value, key: key, - onFieldSubmitted: (_) => send(), - // Don't defocus on submit - onEditingComplete: () {}, - textInputAction: TextInputAction.done, + autofocus: true, focusNode: node, ), ), ), IconButton( - onPressed: !canSendMessages ? null : send, + onPressed: room.canSendDefaultMessages ? send : null, icon: Icon(Icons.send), - tooltip: "Send message", ), ], ), diff --git a/lib/widgets/chat_page/composer/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart deleted file mode 100644 index b650421..0000000 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ /dev/null @@ -1,128 +0,0 @@ -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; -import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/membership_status.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/loading.dart"; - -class MentionOverlay extends ConsumerWidget { - final String? triggerCharacter; - final String query; - final void Function({required String id, required String name}) addTag; - const MentionOverlay({ - required this.query, - required this.addTag, - required this.triggerCharacter, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final rooms = ref.watch(RoomsController.provider); - - return Padding( - padding: EdgeInsets.all(8), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - padding: EdgeInsets.all(8), - child: switch (triggerCharacter) { - "@" => - ref - .watch( - MembersByTypeController.provider(MembershipStatus.join), - ) - .betterWhen( - data: (members) => ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.userId.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - member.displayName - .toLowerCase() - .contains( - query.toLowerCase(), - ) == - true, - )) - .map( - (member) => ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text(member.displayName), - subtitle: Text(member.userId), - onTap: () => addTag( - id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})", - name: member.userId - .substring(1) - .split(":") - .first, - ), - ), - ) - .toList(), - ), - ), - "#" => ListView( - children: - (query.isEmpty - ? rooms.values - : rooms.values.where( - (room) => - (room.metadata?.name ?? room.metadata!.id) - .toLowerCase() - .contains(query.toLowerCase()), - )) - .map((room) { - final name = - room.metadata?.name ?? - room.metadata!.canonicalAlias ?? - room.metadata!.id; - return ListTile( - leading: AvatarOrHash( - room.metadata?.avatar, - name, - fallback: Icon(Icons.numbers), - ), - title: Text(name), - subtitle: room.metadata?.topic == null - ? null - : Text(room.metadata!.topic!, maxLines: 1), - onTap: () { - final vias = ref.watch( - ViaController.provider(room), - ); - addTag( - id: "[#$name](matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - name: - (room.metadata?.canonicalAlias ?? - room.metadata?.id) - ?.substring(1) - .split(":") - .first ?? - "", - ); - }, - ); - }) - .toList(), - ), - - _ => Loading(), - }, - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/composer/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart deleted file mode 100644 index c90b07b..0000000 --- a/lib/widgets/chat_page/composer/relation_preview.dart +++ /dev/null @@ -1,93 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -class RelationPreview extends ConsumerWidget { - final Message? relatedMessage; - final RelationType relationType; - final VoidCallback onDismiss; - final bool shouldMention; - final VoidCallback toggleShouldMention; - - const RelationPreview( - this.relatedMessage, { - required this.relationType, - required this.onDismiss, - required this.shouldMention, - required this.toggleShouldMention, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (relatedMessage == null) return SizedBox.shrink(); - final theme = Theme.of(context); - - return Container( - color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - children: [ - if (relationType == RelationType.edit) - Text( - "Editing message:", - style: TextStyle(fontWeight: FontWeight.bold), - ), - - MessageAvatar(relatedMessage!), - - Expanded( - child: Row( - spacing: 8, - children: [ - Flexible( - child: MessageDisplayname( - relatedMessage!, - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: Text( - relatedMessage?.metadata?["body"] ?? - relatedMessage?.metadata?["eventType"] ?? - "", - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: theme.textTheme.labelMedium, - ), - ), - ], - ), - ), - - if (relationType == RelationType.reply) - TextButton( - onPressed: toggleShouldMention, - child: Text( - shouldMention ? "@On" : "@Off", - style: TextStyle( - fontWeight: FontWeight.w900, - color: shouldMention ? null : Theme.of(context).disabledColor, - ), - ), - ), - - IconButton( - tooltip: - "Cancel ${relationType == RelationType.edit ? "edit" : "reply"}", - onPressed: onDismiss, - icon: const Icon(Icons.close), - iconSize: 20, - ), - ], - ), - ); - } -} diff --git a/lib/widgets/chat_page/emoji_picker_button.dart b/lib/widgets/chat_page/emoji_picker_button.dart deleted file mode 100644 index 0c43c48..0000000 --- a/lib/widgets/chat_page/emoji_picker_button.dart +++ /dev/null @@ -1,41 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; - -class EmojiPickerButton extends HookWidget { - final TextEditingController? controller; - final void Function(String emoji)? onSelection; - final VoidCallback? onPressed; - final BuildContext context; - const EmojiPickerButton({ - this.controller, - this.onPressed, - this.onSelection, - required this.context, - super.key, - }); - - @override - Widget build(_) => IconButton( - onPressed: () { - onPressed?.call(); - final controller = this.controller ?? TextEditingController(); - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: EmojiViewConfig( - showRecentTab: false, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() async { - Navigator.of(context).pop(); - onSelection?.call(controller.text); - }), - ), - ); - }, - icon: Icon(Icons.emoji_emotions), - ); -} diff --git a/lib/widgets/chat_page/expandable_image.dart b/lib/widgets/chat_page/expandable_image.dart deleted file mode 100644 index ac5bbe1..0000000 --- a/lib/widgets/chat_page/expandable_image.dart +++ /dev/null @@ -1,48 +0,0 @@ -import "dart:math"; -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/error_dialog.dart"; - -class ExpandableImage extends ConsumerWidget { - final Widget child; - final String? source; - const ExpandableImage(this.source, {required this.child, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => InkWell( - onTap: source == null - ? null - : () => showDialog( - context: context, - builder: (_) => LayoutBuilder( - builder: (context, constraints) => Dialog( - backgroundColor: Colors.transparent, - insetPadding: EdgeInsets.all(constraints.maxWidth / 100), - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: min(constraints.maxWidth, 1000), - ), - child: InteractiveViewer( - child: Image( - fit: BoxFit.contain, - errorBuilder: (_, error, stackTrace) => ErrorDialog( - "Loading failed for $source\nError: $error", - stackTrace, - ), - image: CachedNetworkImage( - source!, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - ), - ), - ), - ), - ), - ), - child: child, - ); -} diff --git a/lib/widgets/chat_page/expandable_image_message.dart b/lib/widgets/chat_page/expandable_image_message.dart deleted file mode 100644 index f6e8a03..0000000 --- a/lib/widgets/chat_page/expandable_image_message.dart +++ /dev/null @@ -1,35 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; - -class ExpandableImageMessage extends ConsumerWidget { - final ImageMessage message; - final int index; - - const ExpandableImageMessage(this.message, {required this.index, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => ExpandableImage( - message.source, - child: FlyerChatImageMessage( - customImageProvider: CachedNetworkImage( - message.source, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - errorBuilder: (context, error, stackTrace) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - message: message, - index: index, - ), - ); -} diff --git a/lib/widgets/chat_page/html/code_block.dart b/lib/widgets/chat_page/html/code_block.dart index 80950ce..fe5b492 100644 --- a/lib/widgets/chat_page/html/code_block.dart +++ b/lib/widgets/chat_page/html/code_block.dart @@ -41,8 +41,6 @@ class CodeBlock extends StatelessWidget { padding: EdgeInsets.all(8), child: SelectableText( code, - minLines: 1, - maxLines: 99, style: TextStyle(fontFamily: "monospace"), ), ), diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index fb533ad..ce5318a 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -1,53 +1,33 @@ -import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/thumbnail_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; +import "package:nexus/models/image_data.dart"; import "package:nexus/widgets/chat_page/html/mention_chip.dart"; import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; import "package:nexus/widgets/chat_page/html/code_block.dart"; import "package:nexus/widgets/chat_page/html/quoted.dart"; +import "package:nexus/widgets/error_dialog.dart"; class Html extends ConsumerWidget { final String html; - final TextStyle? textStyle; - const Html(this.html, {this.textStyle, super.key}); + final Client client; + const Html(this.html, {required this.client, super.key}); @override Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( html, - textStyle: textStyle, customWidgetBuilder: (element) { - if (element.attributes.keys.contains("data-mx-profile-fallback")) { - return SizedBox.shrink(); - } - if (element.attributes.keys.contains("data-mx-spoiler")) { return InlineCustomWidget(child: SpoilerText(text: element.text)); } - final height = - int.tryParse(element.attributes["height"] ?? "") ?? - (element.attributes.keys.contains("data-mx-emoticon") ? 32 : null) ?? - 300; + final height = int.tryParse(element.attributes["height"] ?? "") ?? 300; final width = int.tryParse(element.attributes["width"] ?? ""); - final src = Uri.tryParse(element.attributes["src"] ?? "") - ?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - "", - ) - .toString(); return switch (element.localName) { "code" => @@ -58,45 +38,61 @@ class Html extends ConsumerWidget { ) : null, - "blockquote" => Quoted(Html(element.innerHtml)), + "blockquote" => Quoted(Html(element.innerHtml, client: client)), "a" => - element.attributes["href"]?.mention == null + element.attributes["href"]?.parseIdentifierIntoParts() == null ? null - : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!), - ), + : InlineCustomWidget(child: MentionChip(element.text)), "img" => - src == null - ? SizedBox.shrink() - : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, - child: ExpandableImage( - src, - child: Image( - image: CachedNetworkImage( - src, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, + element.attributes["src"] == null + ? null + : Consumer( + builder: (_, ref, _) => ref + .watch( + ThumbnailController.provider( + ImageData( + uri: element.attributes["src"]!, + height: height, + width: width, + ), + ), + ) + .when( + data: (uri) { + if (uri == null) return SizedBox.shrink(); + + return InlineCustomWidget( + child: Image.network( + uri, + headers: client.headers, + errorBuilder: (_, error, _) => Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + height: height.toDouble(), + width: width?.toDouble(), + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : CircularProgressIndicator(), + ), + ); + }, + error: ErrorDialog.new, + loading: () => InlineCustomWidget( + child: SizedBox( + width: width?.toDouble(), + height: height.toDouble(), + child: CircularProgressIndicator(), + ), ), ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), - ), - ), ), - // Allowed elements list ("del" || "h1" || "h2" || @@ -143,7 +139,11 @@ class Html extends ConsumerWidget { .mapTo?>( (key, value) => switch (key) { "data-mx-color" => MapEntry("color", value), + "data-mx-bg-color" => MapEntry("background-color", value), + + "edited" => MapEntry("display", "block"), + _ => null, }, ) diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart index 575ad03..f8fdab1 100644 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -1,44 +1,23 @@ import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/user_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:matrix/matrix.dart"; -class MentionChip extends ConsumerWidget { - final String content; - const MentionChip(this.content, {super.key}); +class MentionChip extends StatelessWidget { + final String label; + const MentionChip(this.label, {super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final membership = content.mention!.startsWith("@") == true - ? ref - .watch(UserController.provider(content.mention!)) - .whenOrNull(data: (data) => data) - : null; - - return InkWell( - onTapUp: (details) { - content.mention; - if (membership != null) { - context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ); - } - }, - child: IgnorePointer( - child: Chip( - label: Text( - (membership == null ? null : "@${membership.displayName}") ?? - content.mention!, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - ), + Widget build(BuildContext context) => ActionChip( + label: Text( + label.parseIdentifierIntoParts()?.primaryIdentifier ?? label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, ), - ); - } + ), + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () { + // TODO: Open room or join room dialog, or user popover + showAboutDialog(context: context); + }, + ); } diff --git a/lib/widgets/chat_page/join_dialog.dart b/lib/widgets/chat_page/join_dialog.dart deleted file mode 100644 index e718200..0000000 --- a/lib/widgets/chat_page/join_dialog.dart +++ /dev/null @@ -1,137 +0,0 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/key_controller.dart"; -import "package:nexus/controllers/spaces_controller.dart"; -import "package:nexus/helpers/extensions/link_to_mention.dart"; -import "package:nexus/models/requests/join_room_request.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class JoinDialog extends HookWidget { - final WidgetRef ref; - const JoinDialog(this.ref, {super.key}); - - @override - Widget build(BuildContext context) { - final roomAlias = useTextEditingController(); - return AlertDialog( - title: Text("Join a Room"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Enter the room alias, Matrix URI, or Matrix.to link."), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: roomAlias, - title: "#room:server", - ), - ], - ), - actions: [ - TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - - if (context.mounted) { - final roomIdOrAlias = roomAlias.text.mention ?? roomAlias.text; - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final snackbar = scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Joining room $roomIdOrAlias."), - duration: Duration(days: 999), - ), - ); - - try { - final id = await ref - .watch(ClientController.provider.notifier) - .joinRoom( - JoinRoomRequest( - roomIdOrAlias: roomIdOrAlias, - via: IList( - Uri.tryParse( - roomAlias.text.replaceAll("/#", ""), - )?.queryParametersAll["via"] ?? - [], - ), - ), - ); - - snackbar.close(); - - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text("Room $roomIdOrAlias successfully joined."), - action: SnackBarAction( - label: "Open", - onPressed: () async { - final spaces = ref.watch(SpacesController.provider); - final space = spaces.firstWhereOrNull( - (space) => space.id == id, - ); - - await ref - .watch( - KeyController.provider( - KeyController.spaceKey, - ).notifier, - ) - .set( - space?.id ?? - spaces - .firstWhere( - (space) => space.children.any( - (child) => child.metadata?.id == id, - ), - ) - .id, - ); - - if (space == null) { - await ref - .watch( - KeyController.provider( - KeyController.roomKey, - ).notifier, - ) - .set(id); - } - }, - ), - ), - ); - } catch (error) { - snackbar.close(); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - backgroundColor: Theme.of( - context, - ).colorScheme.errorContainer, - content: Text( - error.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ), - ); - } - } - } - }, - child: Text("Join"), - ), - ], - ); - } -} diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart deleted file mode 100644 index dc8dfef..0000000 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ /dev/null @@ -1,32 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/author_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; - -class MessageAvatar extends ConsumerWidget { - final Message message; - final double height; - const MessageAvatar(this.message, {this.height = 16, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) - .betterWhen( - data: (membership) => InkWell( - onTapUp: (details) => context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ), - child: AvatarOrHash( - membership.avatarUrl, - membership.displayName, - height: height, - ), - ), - loading: () => - AvatarOrHash(null, message.authorId.substring(1), height: height), - ); -} diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart deleted file mode 100644 index 88d2fa6..0000000 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ /dev/null @@ -1,38 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/author_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; - -class MessageDisplayname extends ConsumerWidget { - final Message message; - final TextStyle? style; - final bool clickable; - const MessageDisplayname( - this.message, { - this.clickable = true, - this.style, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AuthorController.provider(message)) - .betterWhen( - data: (membership) => InkWell( - onTapUp: clickable - ? (details) => context.showUserPopover( - membership, - globalPosition: details.globalPosition, - ) - : null, - child: Text( - "${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}", - style: style, - overflow: TextOverflow.ellipsis, - ), - ), - loading: () => Text(""), - ); -} diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 8be1ddd..3e6e82e 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -1,94 +1,64 @@ import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/members_by_type_controller.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/avatar_controller.dart"; +import "package:nexus/controllers/members_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/models/membership_status.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -class MemberList extends HookConsumerWidget { - const MemberList({super.key}); +class MemberList extends ConsumerWidget { + final Room room; + const MemberList(this.room, {super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final status = useState(MembershipStatus.join); - final membersProvider = ref.watch( - MembersByTypeController.provider(status.value), - ); - - return Drawer( - shape: Border(), - child: Column( - spacing: 8, - children: [ - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text("Members"), - actionsPadding: EdgeInsets.only(right: 4), - actions: [ - if (Scaffold.of(context).hasEndDrawer) - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - tooltip: "Close member list", - ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, + Widget build(BuildContext context, WidgetRef ref) => Drawer( + shape: Border(), + child: ref + .watch(MembersController.provider(room)) + .betterWhen( + data: (members) => ListView( children: [ - FilterChip( - label: Text("Joined"), - onSelected: (value) => status.value = MembershipStatus.join, - selected: status.value == MembershipStatus.join, - ), - FilterChip( - label: Text("Invited"), - onSelected: (value) => status.value = MembershipStatus.invite, - selected: status.value == MembershipStatus.invite, - ), - FilterChip( - label: Text("Banned"), - onSelected: (value) => status.value = MembershipStatus.ban, - selected: status.value == MembershipStatus.ban, + AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text("Members"), + actionsPadding: EdgeInsets.only(right: 4), + actions: [ + if (Scaffold.of(context).hasEndDrawer) + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + ), + ], ), + ...members + .where( + (membership) => + membership.content["membership"] == + Membership.join.name, + ) + .map( + (member) => ListTile( + leading: AvatarOrHash( + ref + .watch( + AvatarController.provider( + member.content["avatar_url"].toString(), + ), + ) + .whenOrNull(data: (data) => data), + member.content["displayname"].toString(), + headers: room.client.headers, + ), + title: Text( + member.content["displayname"].toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ), ], ), - membersProvider.betterWhen( - data: (members) => Expanded( - child: ListView( - children: members - .map( - (member) => InkWell( - onTapUp: (details) => context.showUserPopover( - member, - globalPosition: details.globalPosition, - ), - child: ListTile( - leading: AvatarOrHash( - member.avatarUrl, - member.displayName, - ), - title: Text( - member.displayName, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.userId, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ) - .toList(), - ), - ), - ), - ], - ), - ); - } + ), + ); } diff --git a/lib/widgets/chat_page/mention_overlay.dart b/lib/widgets/chat_page/mention_overlay.dart new file mode 100644 index 0000000..6558a9d --- /dev/null +++ b/lib/widgets/chat_page/mention_overlay.dart @@ -0,0 +1,130 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/avatar_controller.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/loading.dart"; + +class MentionOverlay extends ConsumerWidget { + final String? triggerCharacter; + final String query; + final Room room; + final void Function({required String id, required String name}) addTag; + const MentionOverlay( + this.room, { + required this.query, + required this.addTag, + required this.triggerCharacter, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => Padding( + padding: EdgeInsets.all(8), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: EdgeInsets.all(8), + child: switch (triggerCharacter) { + "@" => + ref + .watch(MembersController.provider(room)) + .betterWhen( + data: (members) => ListView( + children: + (query.isEmpty + ? members + : members.where( + (member) => + member.senderId.toLowerCase().contains( + query.toLowerCase(), + ) || + (member.content["displayname"] + as String?) + ?.toLowerCase() + .contains( + query.toLowerCase(), + ) == + true, + )) + .map( + (member) => ListTile( + leading: AvatarOrHash( + ref + .watch( + AvatarController.provider( + member.content["avatar_url"] + .toString(), + ), + ) + .whenOrNull(data: (data) => data), + member.content["displayname"].toString(), + headers: room.client.headers, + ), + title: Text( + member.content["displayname"] as String? ?? + member.senderId, + ), + onTap: () => addTag( + id: member.senderId, + name: member.senderId + .substring(1) + .split(":") + .first, + ), + ), + ) + .toList(), + ), + ), + "#" => + ref + .watch(RoomsController.provider) + .betterWhen( + data: (rooms) => ListView( + children: + (query.isEmpty + ? rooms + : rooms.where( + (room) => room.title.toLowerCase().contains( + query.toLowerCase(), + ), + )) + .map( + (room) => ListTile( + leading: AvatarOrHash( + room.avatar, + room.title, + fallback: Icon(Icons.numbers), + headers: room.roomData.client.headers, + ), + title: Text(room.title), + subtitle: room.roomData.topic.isEmpty + ? null + : Text(room.roomData.topic), + onTap: () => addTag( + id: "[#${room.roomData.getLocalizedDisplayname()}](https://matrix.to/#/${room.roomData.id})", + name: + (room.roomData.canonicalAlias.isEmpty + ? room.roomData.id + : room.roomData.canonicalAlias) + .substring(1) + .split(":") + .first, + ), + ), + ) + .toList(), + ), + ), + _ => Loading(), + }, + ), + ), + ); +} diff --git a/lib/widgets/chat_page/relation_preview.dart b/lib/widgets/chat_page/relation_preview.dart new file mode 100644 index 0000000..07bac4e --- /dev/null +++ b/lib/widgets/chat_page/relation_preview.dart @@ -0,0 +1,79 @@ +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/avatar_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class RelationPreview extends ConsumerWidget { + final Message? relatedMessage; + final RelationType relationType; + final VoidCallback onDismiss; + final Room room; + const RelationPreview({ + required this.relatedMessage, + required this.relationType, + required this.onDismiss, + required this.room, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (relatedMessage == null) return SizedBox.shrink(); + final theme = Theme.of(context); + + return Container( + color: theme.colorScheme.surfaceContainerHigh, + padding: EdgeInsets.symmetric(horizontal: 8), + child: Row( + spacing: 8, + children: [ + SizedBox(width: 4), + if (relationType == RelationType.edit) + Text( + "Editing message:", + style: TextStyle(fontWeight: FontWeight.bold), + ), + AvatarOrHash( + ref + .watch( + AvatarController.provider( + relatedMessage!.metadata!["avatarUrl"], + ), + ) + .whenOrNull(data: (data) => data), + relatedMessage!.metadata!["displayName"].toString(), + headers: room.client.headers, + height: 16, + ), + Text( + relatedMessage!.metadata?["displayName"] ?? + relatedMessage!.authorId, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: Text( + (relatedMessage is TextMessage) + ? (relatedMessage as TextMessage).text + : relatedMessage?.metadata?["body"] ?? + relatedMessage?.metadata?["eventType"], + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium, + maxLines: 1, + ), + ), + IconButton( + onPressed: onDismiss, + icon: Icon(Icons.close), + iconSize: 20, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart deleted file mode 100644 index b999be4..0000000 --- a/lib/widgets/chat_page/reply_widget.dart +++ /dev/null @@ -1,101 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/event_controller.dart"; -import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/message_config.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/widgets/chat_page/html/quoted.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; - -typedef OnTapReply = void Function(Message message)?; - -class ReplyWidget extends ConsumerWidget { - final Message message; - final bool alwaysShow; - final MessageGroupStatus? groupStatus; - final OnTapReply onTapReply; - const ReplyWidget( - this.message, { - required this.groupStatus, - this.onTapReply, - this.alwaysShow = false, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); - return message.replyToMessageId == null || room == null - ? SizedBox.shrink() - : Padding( - padding: EdgeInsets.only(bottom: 12), - child: Quoted( - ref - .watch( - EventController.provider( - GetEventRequest( - room: room, - eventId: message.replyToMessageId!, - ), - ), - ) - .betterWhen( - loading: () => Text("Fetching event..."), - data: (event) => event == null - ? SizedBox.shrink() - : ref - .watch( - MessageController.provider( - MessageConfig(room: room, event: event), - ), - ) - .betterWhen( - loading: () => Text("Parsing message..."), - data: (replyMessage) { - if (replyMessage == null) { - return SizedBox.shrink(); - } - - return InkWell( - onTap: () => onTapReply?.call(replyMessage), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - MessageAvatar(replyMessage), - Flexible( - child: MessageDisplayname( - replyMessage, - clickable: false, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Flexible( - child: Text( - replyMessage.metadata!["body"], - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelMedium, - maxLines: 1, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart index 62e282d..17696dd 100644 --- a/lib/widgets/chat_page/room_appbar.dart +++ b/lib/widgets/chat_page/room_appbar.dart @@ -1,20 +1,20 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/models/full_room.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; import "package:nexus/widgets/chat_page/room_menu.dart"; -class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { +class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { final bool isDesktop; - final void Function(BuildContext context)? onOpenMemberList; + final FullRoom room; + final void Function(BuildContext context) onOpenMemberList; final void Function(BuildContext context) onOpenDrawer; - const RoomAppbar({ + const RoomAppbar( + this.room, { required this.isDesktop, + required this.onOpenMemberList, required this.onOpenDrawer, - this.onOpenMemberList, super.key, }); @@ -22,57 +22,39 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { Size get preferredSize => AppBar().preferredSize; @override - Widget build(BuildContext context, WidgetRef ref) { - final room = ref.watch(SelectedRoomController.provider); - return Appbar( - leading: isDesktop - ? room == null - ? null - : ExpandableImage( - room.metadata?.avatar?.toString(), - child: AvatarOrHash( - room.metadata?.avatar, - room.metadata?.name ?? "Unnamed Rooms", - height: 24, - fallback: Icon(Icons.numbers), - ), - ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), - scrolledUnderElevation: 0, - title: room == null - ? null - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - room.metadata?.name ?? "Unnamed Room", - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - if (room.metadata?.topic?.isNotEmpty == true) - Text( - room.metadata!.topic!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + Widget build(BuildContext context) => Appbar( + leading: isDesktop + ? AvatarOrHash( + room.avatar, + room.title, + height: 24, + fallback: Icon(Icons.numbers), + headers: room.roomData.client.headers, + ) + : DrawerButton(onPressed: () => onOpenDrawer(context)), + scrolledUnderElevation: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(room.title, overflow: TextOverflow.ellipsis), + if (room.roomData.topic.isNotEmpty) + Text( + room.roomData.topic, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - actions: [ - IconButton( - onPressed: null, - icon: Icon(Icons.push_pin), - tooltip: "Open pinned messages", - ), - IconButton( - onPressed: () => onOpenMemberList?.call(context), - tooltip: "Open member list", - icon: Icon(Icons.people), - ), - if (room != null) RoomMenu(room), - ].toIList(), - ); - } + ), + ], + ), + actions: [ + IconButton(onPressed: () {}, icon: Icon(Icons.push_pin)), + IconButton( + onPressed: () => onOpenMemberList(context), + icon: Icon(Icons.people), + ), + RoomMenu(room.roomData), + ], + ); } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 5166d87..0190e64 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,34 +1,26 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_ui/flutter_chat_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:flyer_chat_file_message/flyer_chat_file_message.dart"; +import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; +import "package:flyer_chat_text_message/flyer_chat_text_message.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/account_data_controller.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; -import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/widgets/chat_page/composer/chat_box.dart"; -import "package:nexus/widgets/chat_page/emoji_picker_button.dart"; -import "package:nexus/widgets/chat_page/expandable_image_message.dart"; +import "package:nexus/widgets/chat_page/chat_box.dart"; +import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/member_list.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; +import "package:nexus/widgets/chat_page/top_widget.dart"; import "package:nexus/widgets/form_text_input.dart"; -import "package:nexus/main.dart"; +import "package:nexus/widgets/loading.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; @@ -41,152 +33,109 @@ class RoomChat extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final client = ref.watch(ClientController.provider.notifier); - final relatedMessage = useState(null); + final replyToMessage = useState(null); final memberListOpened = useState(showMembersByDefault); final relationType = useState(RelationType.reply); - final userId = ref.watch(ClientStateController.provider)?.userId; - final roomId = ref.watch( - SelectedRoomController.provider.select((value) => value?.metadata?.id), - ); - final theme = Theme.of(context); final danger = theme.colorScheme.error; - if (roomId == null || userId == null) { - return Scaffold( - appBar: RoomAppbar( - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: null, - ), - body: Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ), - ); - } + return ref + .watch(SelectedRoomController.provider) + .betterWhen( + data: (room) { + if (room == null) { + return Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ); + } + final controllerProvider = RoomChatController.provider( + room.roomData, + ); + final notifier = ref.watch(controllerProvider.notifier); - final controllerProvider = RoomChatController.provider(roomId); - final notifier = ref.watch(controllerProvider.notifier); - - final composerNode = useFocusNode( - onKeyEvent: (_, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.escape) { - relatedMessage.value = null; - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - }, - ); - - List getMessageOptions(Message message) { - final isSentByMe = message.authorId == userId; - return [ - PopupMenuItem( - child: Row( - children: [ - ...{ - ...ref.watch( - AccountDataController.provider.select( - (value) => IList( - value["m.recent_emoji"]?.content["recent_emoji"] ?? - [], - ).map((entry) => entry["emoji"]), - ), - ), - "👍", - "🤣", - "😭", - "🤔", - } - .toIList() - .sublist(0, 4) - .map( - (emoji) => IconButton( - onPressed: () async { - Navigator.of(context).pop(); - await notifier - .sendReaction(emoji, message) - .onError(showError); + List getMessageOptions(Message message) => [ + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.reply; + }, + child: ListTile( + leading: Icon(Icons.reply), + title: Text("Reply"), + ), + ), + if (message.authorId == room.roomData.client.userID) + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.edit; + }, + child: ListTile( + leading: Icon(Icons.edit), + title: Text("Edit"), + ), + ), + if (message.authorId == room.roomData.client.userID || + room.roomData.canRedact) + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final deleteReasonController = + useTextEditingController(); + return AlertDialog( + title: Text("Delete Message"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Are you sure you want to delete this message? This can not be reversed.", + ), + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: deleteReasonController, + title: "Reason for deletion (optional)", + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + notifier.deleteMessage( + message, + reason: deleteReasonController.text, + ); + Navigator.of(context).pop(); + }, + child: Text("Delete"), + ), + ], + ); }, - icon: Text(emoji), ), ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, message).onError(showError), - ), - ], - ), - ), - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.reply; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), - if (message is TextMessage && isSentByMe) - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.edit; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), - ), - PopupMenuItem( - onTap: () async { - final room = ref.watch(SelectedRoomController.provider); - if (room == null) return; - - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.redaction"), - ), - )) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: deleteReasonController, - title: "Reason for deletion (optional)", - ), - ], + child: ListTile( + leading: Icon(Icons.delete), + title: Text("Delete"), + ), + ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Report"), + content: Text( + "Report this message to your server administrators, who can take action like banning that user or blocking that server from federating.", ), actions: [ TextButton( @@ -194,287 +143,249 @@ class RoomChat extends HookConsumerWidget { child: Text("Cancel"), ), TextButton( - onPressed: () async { + onPressed: () { + room.roomData.client.reportEvent( + room.roomData.id, + message.id, + ); Navigator.of(context).pop(); - await notifier - .deleteMessage( - message, - reason: deleteReasonController.text, - ) - .onError(showError); }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: TextStyle(color: danger)), - ), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final reasonController = useTextEditingController(); - return AlertDialog( - title: Text("Report"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Report this event to your server administrators, who can take action like banning this server or room.", - ), - - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: reasonController, - title: "Reason for report (optional)", + child: Text("Report"), ), ], ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - client.reportEvent( - ReportRequest( - roomId: roomId, - eventId: message.id, - reason: reasonController.text.isEmpty - ? null - : reasonController.text, - ), - ); - Navigator.of(context).pop(); - }, - child: Text("Report"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: TextStyle(color: danger)), - ), - ), - ]; - } - - final chatTheme = ChatTheme.fromThemeData(theme).copyWith( - colors: ChatColors.fromThemeData(theme).copyWith( - primary: theme.colorScheme.primaryContainer, - onPrimary: theme.colorScheme.onPrimaryContainer, - ), - ); - - return Scaffold( - appBar: RoomAppbar( - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Column( - children: [ - Expanded( - child: ref - .watch(controllerProvider) - .betterWhen( - data: (controller) => Chat( - currentUserId: userId, - theme: chatTheme, - onMessageSecondaryTap: - ( - context, - message, { - required index, - TapUpDetails? details, - }) => details?.globalPosition == null - ? null - : context.showContextMenu( - globalPosition: details!.globalPosition, - children: getMessageOptions(message), - ), - onMessageLongPress: - ( - context, - message, { - required details, - required index, - }) => context.showContextMenu( - globalPosition: details.globalPosition, - children: getMessageOptions(message), - ), - builders: Builders( - loadMoreBuilder: (_) => SizedBox.shrink(), - - chatAnimatedListBuilder: (_, itemBuilder) => - ChatAnimatedList( - itemBuilder: itemBuilder, - onEndReached: - ref.watch( - SelectedRoomController.provider.select( - (room) => room?.hasMore == true, - ), - ) - ? notifier.loadOlder - : null, - onStartReached: () async { - final room = ref.watch( - SelectedRoomController.provider, - ); - return room == null - ? null - : await client.markRead(room); - }, - bottomPadding: 72, - ), - - composerBuilder: (_) => ChatBox( - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier.send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relation: relatedMessage.value, - ), - relationType: relationType.value, - relatedMessage: relatedMessage.value, - onDismiss: () => relatedMessage.value = null, - ), - - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - ), - - imageMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - extra: ExpandableImageMessage( - message, - index: index, - ), - ), - - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => MessageWrapper( - message, - InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - child: Text( - "TODO: Download Attachments", - ), - ), - ), - child: FlyerChatFileMessage( - topWidget: ReplyWidget( - message, - onTapReply: notifier.scrollToMessage, - groupStatus: groupStatus, - ), - message: message, - index: index, - ), - ), - groupStatus, - ), - - systemMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatSystemMessage( - message: message, - index: index, - ), - - unsupportedMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - resolveUser: (_) async => null, - chatController: controller, - ), - ), ), - ], - ), - ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: TextStyle(color: danger)), + ), + ), + ]; - if (memberListOpened.value == true && showMembersByDefault) - MemberList(), - ], - ), + return Scaffold( + appBar: RoomAppbar( + room, + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: (thisContext) { + memberListOpened.value = !memberListOpened.value; + Scaffold.of(thisContext).openEndDrawer(); + }, + ), + body: Row( + children: [ + Expanded( + child: Column( + children: [ + Expanded( + child: ref + .watch(controllerProvider) + .betterWhen( + data: (controller) => Chat( + currentUserId: room.roomData.client.userID!, + theme: ChatTheme.fromThemeData(theme) + .copyWith( + colors: ChatColors.fromThemeData(theme) + .copyWith( + primary: theme + .colorScheme + .primaryContainer, + onPrimary: theme + .colorScheme + .onPrimaryContainer, + ), + ), + onMessageSecondaryTap: + ( + context, + message, { + required details, + required index, + }) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getMessageOptions(message), + ), + onMessageLongPress: + ( + context, + message, { + required details, + required index, + }) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getMessageOptions(message), + ), + builders: Builders( + loadMoreBuilder: (_) => Loading(), + chatAnimatedListBuilder: (_, itemBuilder) => + ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: notifier.loadOlder, + onStartReached: notifier.markRead, + bottomPadding: 72, + ), + composerBuilder: (_) => ChatBox( + relationType: relationType.value, + relatedMessage: replyToMessage.value, + onDismiss: () => + replyToMessage.value = null, + room: room.roomData, + ), + textMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatTextMessage( + customWidget: Html( + (message.metadata?["formatted"] + as String) + .replaceAllMapped( + RegExp( + regexLink, + caseSensitive: false, + ), + (m) => + "${m.group(0)!}", + ) + .replaceAll("\n", "
") + + ((message.editedAt != null) + ? "(edited)" + : ""), + client: room.roomData.client, + ), + topWidget: TopWidget( + message, + headers: + room.roomData.client.headers, + groupStatus: groupStatus, + ), + message: message, + showTime: true, + index: index, + ), + linkPreviewBuilder: + (_, message, isSentByMe) => LinkPreview( + text: message.text, + backgroundColor: isSentByMe + ? theme.colorScheme.inversePrimary + : theme + .colorScheme + .surfaceContainerLow, + insidePadding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + linkPreviewData: + message.linkPreviewData, + onLinkPreviewDataFetched: + (linkPreviewData) => + notifier.updateMessage( + message, + message.copyWith( + linkPreviewData: + linkPreviewData, + ), + ), + ), + imageMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatImageMessage( + topWidget: TopWidget( + message, + headers: + room.roomData.client.headers, + groupStatus: groupStatus, + alwaysShow: true, + ), + errorBuilder: + (context, error, stackTrace) => + Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), + ), + ), + message: message, + index: index, + headers: room.roomData.client.headers, + ), + fileMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => InkWell( + onTap: () => showAboutDialog( + context: context, + ), // TODO: Download + child: FlyerChatFileMessage( + topWidget: TopWidget( + message, + headers: + room.roomData.client.headers, + groupStatus: groupStatus, + ), + message: message, + index: index, + ), + ), + systemMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatSystemMessage( + message: message, + index: index, + ), + unsupportedMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => Text( + "${message.authorId} sent ${message.metadata?["eventType"]}", + style: theme.textTheme.labelSmall + ?.copyWith(color: Colors.grey), + ), + ), + resolveUser: notifier.resolveUser, + chatController: controller, + ), + ), + ), + ], + ), + ), - endDrawer: showMembersByDefault ? null : MemberList(), - ); + if (memberListOpened.value == true && showMembersByDefault) + MemberList(room.roomData), + ], + ), + + endDrawer: showMembersByDefault + ? null + : MemberList(room.roomData), + ); + }, + ); } } diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart index 4405707..c5fa322 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -1,42 +1,23 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:clipboard/clipboard.dart"; import "package:flutter/material.dart"; -import "package:flutter/services.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/via_controller.dart"; -import "package:nexus/models/room.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/widgets/form_text_input.dart"; -class RoomMenu extends ConsumerWidget { +class RoomMenu extends StatelessWidget { final Room room; - final IList children; - const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); + const RoomMenu(this.room, {super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final danger = Theme.of(context).colorScheme.error; - final client = ref.watch(ClientController.provider.notifier); return PopupMenuButton( itemBuilder: (_) => [ PopupMenuItem( onTap: () async { - await client.markRead(room); - await Future.wait(children.map((child) => client.markRead(child))); - }, - child: ListTile( - leading: Icon(Icons.check), - title: Text("Mark as Read"), - ), - ), - PopupMenuItem( - onTap: () async { - final vias = ref.watch(ViaController.provider(room)); - - await Clipboard.setData( - ClipboardData( - text: "matrix:roomid/${room.metadata?.id.substring(1)}$vias)", - ), - ); + final link = await room.matrixToInviteLink(); + await FlutterClipboard.copy(link.toString()); }, child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), ), @@ -46,7 +27,7 @@ class RoomMenu extends ConsumerWidget { builder: (context) => AlertDialog( title: Text("Leave Room"), content: Text( - "Are you sure you want to leave \"${room.metadata?.name ?? "Unnamed Room"}\"?", + "Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?", ), actions: [ TextButton( @@ -56,13 +37,10 @@ class RoomMenu extends ConsumerWidget { TextButton( onPressed: () async { Navigator.of(context).pop(); - final snackbar = ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Leaving room..."), - duration: Duration(days: 1), - ), - ); - await client.leaveRoom(room); + final snackbar = ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Leaving room..."))); + await room.leave(); snackbar.close(); }, child: Text("Leave"), @@ -75,53 +53,52 @@ class RoomMenu extends ConsumerWidget { title: Text("Leave", style: TextStyle(color: danger)), ), ), - // PopupMenuItem( - // onTap: () => showDialog( - // context: context, - // builder: (context) => HookBuilder( - // builder: (_) { - // final reasonController = useTextEditingController(); - // return AlertDialog( - // title: Text("Report"), - // content: Column( - // mainAxisSize: MainAxisSize.min, - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // "Report this room to your server administrators, who can take action like banning this room.", - // ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final reasonController = useTextEditingController(); + return AlertDialog( + title: Text("Report"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Report this room to your server administrators, who can take action like banning this room.", + ), - // SizedBox(height: 12), - // FormTextInput( - // required: false, - // capitalize: true, - // controller: reasonController, - // title: "Reason for report (optional)", - // ), - // ], - // ), - // actions: [ - // TextButton( - // onPressed: Navigator.of(context).pop, - // child: Text("Cancel"), - // ), - // TextButton( - // onPressed: () { - // room.client.reportRoom(room.id, reasonController.text); - // Navigator.of(context).pop(); - // }, - // child: Text("Report"), - // ), - // ], - // ); - // }, - // ), - // ), - // child: ListTile( - // leading: Icon(Icons.report, color: danger), - // title: Text("Report", style: TextStyle(color: danger)), - // ), - // ), + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: reasonController, + title: "Reason for deletion (optional)", + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + room.client.reportRoom(room.id, reasonController.text); + Navigator.of(context).pop(); + }, + child: Text("Report"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: TextStyle(color: danger)), + ), + ), ], ); } diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart index f79c38f..d4bd89d 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/chat_page/sidebar.dart @@ -1,182 +1,175 @@ +import "package:collection/collection.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/selected_space_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/pages/settings_page.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/widgets/chat_page/join_dialog.dart"; import "package:nexus/widgets/chat_page/room_menu.dart"; class Sidebar extends HookConsumerWidget { - final bool isDesktop; - const Sidebar({required this.isDesktop, super.key}); + const Sidebar({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final selectedSpaceProvider = KeyController.provider( KeyController.spaceKey, ); - final selectedSpaceId = ref.watch(selectedSpaceProvider); - final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier); + final selectedSpace = ref.watch(selectedSpaceProvider); + final selectedSpaceNotifier = ref.watch(selectedSpaceProvider.notifier); final selectedRoomController = KeyController.provider( KeyController.roomKey, ); - final selectedRoomId = ref.watch(selectedRoomController); - final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier); - - final spaces = ref.watch(SpacesController.provider); - final indexOfSelected = spaces.indexWhere( - (space) => space.id == selectedSpaceId, - ); - final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected; - - final selectedSpace = ref.watch(SelectedSpaceController.provider); - - final indexOfSelectedRoom = selectedSpace.children.indexWhere( - (room) => room.metadata?.id == selectedRoomId, - ); - final selectedRoomIndex = indexOfSelectedRoom == -1 - ? selectedSpace.children.isEmpty - ? null - : 0 - : indexOfSelectedRoom; + final selectedRoom = ref.watch(selectedRoomController); + final selectedRoomNotifier = ref.watch(selectedRoomController.notifier); return Drawer( shape: Border(), child: Row( children: [ - NavigationRail( - scrollable: true, - onDestinationSelected: (value) { - selectedSpaceIdNotifier.set(spaces[value].id); - selectedRoomIdNotifier.set( - spaces[value].children.firstOrNull?.metadata?.id, - ); - }, - destinations: spaces - .map( - (space) => NavigationRailDestination( - icon: AvatarOrHash( - space.room?.metadata?.avatar, - fallback: space.icon == null ? null : Icon(space.icon), - space.title, - hasBadge: space.children.any( - (room) => room.metadata?.unreadMessages != 0, - ), - badgeNumber: space.children.fold( - 0, - (previousValue, room) => - previousValue + - (room.metadata?.unreadNotifications ?? 0), - ), - ), - label: Text(space.title), - padding: EdgeInsets.only(top: 4), - ), - ) - .toList(), - selectedIndex: selectedIndex, - trailingAtBottom: true, - trailing: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Column( - spacing: 8, - children: [ - PopupMenuButton( - itemBuilder: (_) => [ - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (_) => JoinDialog(ref), - ), - child: ListTile( - title: Text("Join an existing room (or space)"), - leading: Icon(Icons.numbers), - ), - ), - PopupMenuItem( - onTap: null, - child: ListTile( - title: Text("Create a new room"), - leading: Icon(Icons.add), - ), - ), - ], - icon: Icon(Icons.add), - ), - IconButton( - tooltip: "Explore other rooms", - onPressed: null, - icon: Icon(Icons.explore), - ), - IconButton( - tooltip: "Open settings", - onPressed: null, - // () => Navigator.of( - // context, - // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), - icon: Icon(Icons.settings), - ), - ], - ), - ), - ), - Expanded( - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - leading: AvatarOrHash( - selectedSpace.room?.metadata?.avatar, - fallback: selectedSpace.icon == null - ? null - : Icon(selectedSpace.icon), - - selectedSpace.title, - ), - title: Text( - selectedSpace.title, - overflow: TextOverflow.ellipsis, - ), - backgroundColor: Colors.transparent, - actions: [ - if (selectedSpace.room != null) - RoomMenu( - selectedSpace.room!, - children: selectedSpace.children, - ), - ], - ), - body: NavigationRail( - scrollable: true, - backgroundColor: Colors.transparent, - extended: true, - selectedIndex: selectedRoomIndex, - destinations: selectedSpace.children - .map( - (room) => NavigationRailDestination( - label: Text(room.metadata?.name ?? "Unnamed Room"), - icon: AvatarOrHash( - room.metadata?.avatar, - hasBadge: room.metadata?.unreadMessages != 0, - badgeNumber: room.metadata?.unreadNotifications ?? 0, - room.metadata?.name ?? "Unnamed Room", - fallback: selectedSpaceId == "dms" - ? null - : Icon(Icons.numbers), - // space.client.headers, - ), - ), - ) - .toList(), - onDestinationSelected: (value) { - selectedRoomIdNotifier.set( - selectedSpace.children[value].metadata?.id, + ref + .watch(SpacesController.provider) + .when( + loading: SizedBox.shrink, + error: (error, stack) { + debugPrintStack(label: error.toString(), stackTrace: stack); + throw error; + }, + data: (spaces) { + final indexOfSelected = spaces.indexWhere( + (space) => space.id == selectedSpace, + ); + final selectedIndex = indexOfSelected == -1 + ? 0 + : indexOfSelected; + + return NavigationRail( + scrollable: true, + onDestinationSelected: (value) { + selectedSpaceNotifier.set(spaces[value].id); + selectedRoomNotifier.set( + spaces[value].children.firstOrNull?.roomData.id, + ); + }, + destinations: spaces + .map( + (space) => NavigationRailDestination( + icon: AvatarOrHash( + space.avatar, + fallback: space.icon == null + ? null + : Icon(space.icon), + space.title, + headers: space.client.headers, + hasBadge: + space.children.firstWhereOrNull( + (room) => room.roomData.hasNewMessages, + ) != + null, + ), + label: Text(space.title), + padding: EdgeInsets.only(top: 4), + ), + ) + .toList(), + selectedIndex: selectedIndex, + trailingAtBottom: true, + trailing: Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Column( + spacing: 8, + children: [ + IconButton( + onPressed: () => Navigator.of(context).push( + // TODO: join or create room/space + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.add), + ), + IconButton( + onPressed: () => Navigator.of(context).push( + // TODO: explore public rooms/spaces + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.explore), + ), + IconButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => SettingsPage()), + ), + icon: Icon(Icons.settings), + ), + ], + ), + ), ); - if (!isDesktop) Navigator.of(context).pop(); }, ), - ), + Expanded( + child: ref + .watch(SelectedSpaceController.provider) + .betterWhen( + data: (space) { + final indexOfSelected = space.children.indexWhere( + (room) => room.roomData.id == selectedRoom, + ); + final selectedIndex = indexOfSelected == -1 + ? space.children.isEmpty + ? null + : 0 + : indexOfSelected; + + return Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + leading: AvatarOrHash( + space.avatar, + fallback: space.icon == null + ? null + : Icon(space.icon), + space.title, + headers: space.client.headers, + ), + title: Text( + space.title, + overflow: TextOverflow.ellipsis, + ), + backgroundColor: Colors.transparent, + actions: [ + if (space.roomData != null) RoomMenu(space.roomData!), + ], + ), + body: NavigationRail( + scrollable: true, + backgroundColor: Colors.transparent, + extended: true, + selectedIndex: selectedIndex, + destinations: space.children + .map( + (room) => NavigationRailDestination( + label: Text(room.title), + icon: AvatarOrHash( + hasBadge: room.roomData.hasNewMessages, + room.avatar, + room.title, + fallback: selectedSpace == "dms" + ? null + : Icon(Icons.numbers), + headers: space.client.headers, + ), + ), + ) + .toList(), + onDestinationSelected: (value) => selectedRoomNotifier + .set(space.children[value].roomData.id), + ), + ); + }, + ), ), ], ), diff --git a/lib/widgets/chat_page/top_widget.dart b/lib/widgets/chat_page/top_widget.dart new file mode 100644 index 0000000..733dcc7 --- /dev/null +++ b/lib/widgets/chat_page/top_widget.dart @@ -0,0 +1,116 @@ +import "dart:math"; +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_chat_ui/flutter_chat_ui.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/widgets/chat_page/html/quoted.dart"; + +class TopWidget extends ConsumerWidget { + final Message message; + final bool alwaysShow; + final Map headers; + final MessageGroupStatus? groupStatus; + const TopWidget( + this.message, { + required this.headers, + required this.groupStatus, + this.alwaysShow = false, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Builder( + builder: (_) { + final replyMessage = message.metadata?["reply"] as TextMessage?; + + if (replyMessage == null) return SizedBox.shrink(); + final smallerText = message is TextMessage + ? replyMessage.text.substring( + 0, + min( + max( + max( + (message as TextMessage).text.length - 20, + message.metadata?["displayName"].length, + ), + 5, + ), + replyMessage.text.length, + ), + ) + : null; + final replyText = + (smallerText == null || + smallerText.length == replyMessage.text.length) + ? replyMessage.text + : "$smallerText..."; + + return Padding( + padding: EdgeInsets.only(bottom: 12), + child: InkWell( + // TODO: Scroll to original message + onTap: () => showAboutDialog(context: context), + child: Quoted( + Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Avatar( + userId: replyMessage.authorId, + headers: headers, + size: 16, + ), + Flexible( + child: Text( + replyMessage.metadata?["displayName"] ?? + replyMessage.authorId, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: Text( + replyText, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + maxLines: 1, + ), + ), + ], + ), + ), + ), + ); + }, + ), + if (alwaysShow || + groupStatus?.isFirst != false || + message.metadata?["reply"] != null) + InkWell( + onTap: () => + showAboutDialog(context: context), // TODO: Show user profile + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Avatar(userId: message.authorId, headers: headers), + Flexible( + child: Text( + message.metadata?["displayName"] ?? message.authorId, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + SizedBox(height: 4), + ], + ); +} diff --git a/lib/widgets/chat_page/user_popover.dart b/lib/widgets/chat_page/user_popover.dart deleted file mode 100644 index a9a4799..0000000 --- a/lib/widgets/chat_page/user_popover.dart +++ /dev/null @@ -1,214 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:intl/intl.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/power_level_config.dart"; -import "package:nexus/models/membership.dart"; -import "package:nexus/models/membership_status.dart"; -import "package:nexus/models/requests/membership_action.dart"; -import "package:nexus/models/requests/set_membership_request.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/chat_page/expandable_image.dart"; -import "package:nexus/widgets/form_text_input.dart"; - -class UserPopover extends ConsumerWidget { - final Membership member; - const UserPopover(this.member, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final client = ref.watch(ClientController.provider.notifier); - final roomId = ref.watch( - SelectedRoomController.provider.select((room) => room?.metadata?.id), - ); - - void showMembershipDialog(MembershipAction action) => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (context) { - final actionReasonController = useTextEditingController(); - return AlertDialog( - title: Text( - "${toBeginningOfSentenceCase(action.name)} ${member.userId}", - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to ${action.name} ${member.userId}?", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: actionReasonController, - title: "Reason for ${action.name} (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - client - .setMembership( - SetMembershipRequest( - userId: member.userId, - roomId: roomId!, - action: action, - reason: actionReasonController.text, - ), - ) - .onError(showError); - }, - child: Text(toBeginningOfSentenceCase(action.name)), - ), - ], - ); - }, - ), - ); - - return Column( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Wrap( - alignment: WrapAlignment.center, - spacing: 16, - runSpacing: 8, - children: [ - ExpandableImage( - member.avatarUrl?.toString(), - child: AvatarOrHash( - member.avatarUrl, - member.displayName, - height: 80, - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText( - member.displayName, - style: textTheme.headlineSmall, - ), - SelectableText(member.userId, style: textTheme.titleSmall), - SizedBox(height: 4), - ref - .watch(ProfileController.provider(member.userId)) - .betterWhen( - loading: SizedBox.shrink, - data: (profile) => Wrap( - spacing: 4, - children: [ - for (final pronoun in profile.pronouns.where( - (pronoun) => pronoun.language == "en", - )) - Chip( - label: Text(pronoun.summary), - labelStyle: TextStyle( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - if (profile.timezone != null) - Chip( - label: Text(profile.timezone!), - labelStyle: TextStyle( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - ], - ), - ), - ], - ), - ], - ), - if (member.userId != - ref.watch(ClientStateController.provider)?.userId && - roomId != null) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon(onPressed: null, label: Text("Message")), - - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", - action: MembershipAction.kick, - isStateEvent: true, - targetUser: member.userId, - ), - ), - ) && - member.status == MembershipStatus.join || - member.status == MembershipStatus.invite) - FilledButton.icon( - onPressed: () => showMembershipDialog(MembershipAction.kick), - label: Text("Kick"), - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.error, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), - ), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig( - eventType: "m.room.member", - action: MembershipAction.ban, - isStateEvent: true, - targetUser: member.userId, - ), - ), - )) - ElevatedButton.icon( - onPressed: () => showMembershipDialog( - member.status == MembershipStatus.ban - ? MembershipAction.unban - : MembershipAction.ban, - ), - label: Text( - member.status == MembershipStatus.ban ? "Unban" : "Ban", - ), - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.errorContainer, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ], - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart deleted file mode 100644 index 9c70c27..0000000 --- a/lib/widgets/chat_page/wrappers/message_wrapper.dart +++ /dev/null @@ -1,83 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; -import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; -import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart"; -import "package:timeago/timeago.dart"; - -class MessageWrapper extends StatelessWidget { - final Message message; - final Widget child; - final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final error = message.metadata?["error"]; - - return ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: AnimatedContainer( - padding: message.metadata?["flashing"] == true - ? EdgeInsets.all(8) - : EdgeInsets.all(0), - color: message.metadata?["flashing"] == true - ? Theme.of(context).colorScheme.onSurface.withAlpha(50) - : Colors.transparent, - duration: Duration(milliseconds: 250), - child: Row( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - groupStatus?.isFirst != false - ? MessageAvatar(message, height: 40) - : SizedBox(width: 40), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - if (groupStatus?.isFirst != false) - Row( - spacing: 4, - children: [ - Flexible( - child: MessageDisplayname( - message, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - if (message.deliveredAt != null && - groupStatus?.isFirst != false) - Tooltip( - message: message.deliveredAt!.toString(), - child: Text( - format(message.deliveredAt!), - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), - ), - ), - ], - ), - child, - if (error != null && error != "not sent") - Text( - error, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), - ), - ReactionRow(message), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/reaction_row.dart b/lib/widgets/chat_page/wrappers/reaction_row.dart deleted file mode 100644 index f20d2ad..0000000 --- a/lib/widgets/chat_page/wrappers/reaction_row.dart +++ /dev/null @@ -1,110 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/controllers/selected_room_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/main.dart"; - -class ReactionRow extends ConsumerWidget { - final Message message; - const ReactionRow(this.message, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clientState = ref.watch(ClientStateController.provider); - - return Wrap( - spacing: 4, - runSpacing: 4, - children: clientState?.homeserverUrl == null || message.reactions == null - ? [] - : message.reactions! - .mapTo( - (reaction, reactors) => HookBuilder( - builder: (context) { - final enabled = useState(true); - final selected = reactors.contains(clientState!.userId); - return SizedBox( - child: Tooltip( - message: reactors.join(", "), - child: ChoiceChip( - showCheckmark: false, - selected: selected, - label: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - reaction.startsWith("mxc://") - ? Image( - height: 20, - image: CachedNetworkImage( - headers: ref.headers, - Uri.parse(reaction) - .mxcToHttps( - clientState.homeserverUrl!, - ) - .toString(), - ref.watch( - CrossCacheController.provider, - ), - ), - ) - : Text(reaction), - Text(reactors.length.toString()), - ], - ), - onSelected: enabled.value - ? (value) async { - enabled.value = false; - try { - final roomId = ref.watch( - SelectedRoomController.provider.select( - (value) => value?.metadata?.id, - ), - ); - if (roomId == null || - clientState.userId == null) { - return; - } - - final controller = ref.watch( - RoomChatController.provider( - roomId, - ).notifier, - ); - - if (selected) { - await controller - .removeReaction( - reaction, - message, - clientState.userId!, - ) - .onError(showError); - } else { - await controller - .sendReaction(reaction, message) - .onError(showError); - } - } finally { - enabled.value = true; - } - } - : null, - ), - ), - ); - }, - ), - ) - .toList(), - ); - } -} diff --git a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart deleted file mode 100644 index 8d7a625..0000000 --- a/lib/widgets/chat_page/wrappers/text_message_wrapper.dart +++ /dev/null @@ -1,147 +0,0 @@ -import "package:cross_cache/cross_cache.dart"; -import "package:flutter/material.dart"; -import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:flutter_link_previewer/flutter_link_previewer.dart"; -import "package:flutter_linkify/flutter_linkify.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/url_preview_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; -import "package:nexus/widgets/chat_page/reply_widget.dart"; - -class TextMessageWrapper extends ConsumerWidget { - final Message message; - final String? content; - final MessageGroupStatus? groupStatus; - final Future Function(Message oldMessage, Message newMessage) - updateMessage; - final bool isSentByMe; - final Widget? extra; - final OnTapReply onTapReply; - - const TextMessageWrapper( - this.message, { - this.content, - this.onTapReply, - required this.updateMessage, - required this.groupStatus, - required this.isSentByMe, - this.extra, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textMessage = message is TextMessage ? message as TextMessage : null; - - final link = textMessage == null - ? null - : RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(textMessage.text).firstOrNull?.group(0); - - return MessageWrapper( - message, - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: isSentByMe - ? (message.id.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ReplyWidget( - message, - groupStatus: groupStatus, - onTapReply: onTapReply, - ), - if (content != null) - message.metadata?["format"] == "org.matrix.custom.html" - ? Html( - textStyle: message.metadata?["big"] == true - ? TextStyle(fontSize: 32) - : null, - content!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : Linkify( - text: content!, - options: LinkifyOptions(humanize: false), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - if (textMessage?.editedAt != null) - Text("(edited)", style: theme.textTheme.labelSmall), - if (link != null) - ref - .watch(UrlPreviewController.provider(link)) - .betterWhen( - loading: SizedBox.shrink, - data: (preview) => preview == null - ? SizedBox.shrink() - : LinkPreview( - onTap: (url) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(url)), - imageBuilder: (url) => Image( - image: CachedNetworkImage( - url, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => SizedBox.shrink(), - ), - text: link, - backgroundColor: isSentByMe - ? colorScheme.inversePrimary - : colorScheme.surfaceContainerLow, - outsidePadding: EdgeInsets.only(top: 4), - insidePadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - linkPreviewData: preview, - onLinkPreviewDataFetched: (_) => null, - ), - ), - if (extra != null) extra!, - ], - ), - ), - ), - groupStatus, - ); - } -} diff --git a/lib/widgets/form_text_input.dart b/lib/widgets/form_text_input.dart index 21b2e5c..492439b 100644 --- a/lib/widgets/form_text_input.dart +++ b/lib/widgets/form_text_input.dart @@ -20,13 +20,11 @@ class FormTextInput extends StatelessWidget { final Widget? trailing; final InputBorder? border; final List? formatters; - final bool autofocus; const FormTextInput({ super.key, this.border, this.controller, - this.autofocus = false, this.title, this.obscure = false, this.readOnly = false, @@ -47,7 +45,6 @@ class FormTextInput extends StatelessWidget { @override Widget build(BuildContext context) => TextFormField( - autofocus: autofocus, controller: controller, keyboardType: keyboardType, readOnly: readOnly, diff --git a/lib/widgets/loading.dart b/lib/widgets/loading.dart index 9bb2858..aadc43c 100644 --- a/lib/widgets/loading.dart +++ b/lib/widgets/loading.dart @@ -1,14 +1,13 @@ import "package:flutter/material.dart"; class Loading extends StatelessWidget { - final double? height; - const Loading({this.height, super.key}); + const Loading({super.key}); @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: EdgeInsets.all(16), - child: SizedBox(height: height, child: CircularProgressIndicator()), - ), - ); + Widget build(BuildContext context) => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ); } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index fee47c5..2e0c766 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "nexus") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "nexus.federated.Nexus") +set(APPLICATION_ID "nexus.federated.nexus") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 5485b95..fd8ccf3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,8 +9,11 @@ #include #include #include +#include #include +#include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = @@ -22,10 +25,19 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) simple_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SimpleSecureStorageLinuxPlugin"); + simple_secure_storage_linux_plugin_register_with_registrar(simple_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) webcrypto_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WebcryptoPlugin"); + webcrypto_plugin_register_with_registrar(webcrypto_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); + g_autoptr(FlPluginRegistrar) window_size_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); + window_size_plugin_register_with_registrar(window_size_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 13ef2de..8d79b66 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,11 +6,15 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_linux screen_retriever_linux + simple_secure_storage_linux url_launcher_linux + webcrypto window_manager + window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_vodozemac ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/linux/nexus.federated.Nexus.desktop b/linux/nexus.federated.Nexus.desktop deleted file mode 100644 index d3fa575..0000000 --- a/linux/nexus.federated.Nexus.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Name=Nexus -GenericName=Matrix Client -Comment=A simple and user-friendly Matrix client -Exec=nexus -Icon=nexus -Terminal=false -Type=Application -Categories=Chat;Network;InstantMessaging; \ No newline at end of file diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix deleted file mode 100644 index 91ba95a..0000000 --- a/linux/nix/devshell.nix +++ /dev/null @@ -1,41 +0,0 @@ -{ pkgs, lib }: -let - android = pkgs.androidenv.composeAndroidPackages { - toolsVersion = "26.1.1"; - platformToolsVersion = "36.0.1"; - buildToolsVersions = [ - "35.0.0" - "36.0.0" - ]; - cmakeVersions = [ "3.22.1" ]; - platformVersions = [ "36" ]; - abiVersions = [ - "armeabi-v7a" - "arm64-v8a" - ]; - includeNDK = true; - ndkVersions = [ "28.2.13676358" ]; - }; -in -pkgs.mkShell { - packages = with pkgs; [ - go - git - jdk17 - flutter - android.platform-tools - ]; - - env = rec { - LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; - - ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; - ANDROID_SDK_ROOT = ANDROID_HOME; - JAVA_HOME = pkgs.jdk17; - - TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}"; - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2"; - }; -} diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix deleted file mode 100644 index 26f2a17..0000000 --- a/linux/nix/pkg/default.nix +++ /dev/null @@ -1,45 +0,0 @@ -{ - lib, - callPackage, - libclang, - flutter, - src, -}: - -flutter.buildFlutterApplication { - pname = "nexus"; - version = "0.1.0"; - inherit src; - - preBuild = '' - cp ${callPackage ./gomuks.nix { inherit src; }}/lib/* . - packageRunCustom nexus generate source/scripts test - packageRun build_runner build - ''; - - env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; - - autoPubspecLock = src + "/pubspec.lock"; - - gitHashes = { - window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; - dynamic_system_colors = "sha256-es6rjMK1drkqZBKYUP77yw/q5+0uLwWOEDOXRawy3Dc="; - flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; - flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; - emoji_text_field = "sha256-F0QbIHP3wpKoL6QbJ20Oun0SsOdwnXe84IqsK2ad85w="; - }; - - postInstall = '' - install -D assets/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg - install -Dm755 linux/nexus.federated.Nexus.desktop -t $out/share/applications - wrapProgram $out/bin/nexus \ - --suffix LD_LIBRARY_PATH : $out/app/nexus/lib - ''; - - meta = { - description = "A simple and user-friendly Matrix client"; - mainProgram = "nexus"; - platforms = lib.platforms.linux; - maintainers = with lib.maintainers; [ quadradical ]; - }; -} diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix deleted file mode 100644 index 1bc92bf..0000000 --- a/linux/nix/pkg/gomuks.nix +++ /dev/null @@ -1,31 +0,0 @@ -{ - src, - buildGoModule, -}: - -buildGoModule (finalAttrs: { - pname = "gomuks-ffi"; - version = "submodule"; - - doCheck = false; - - src = "${src}/gomuks"; - - vendorHash = "sha256-zBDfBZqUoHIfZ0AajZEvSBbskjpFB7yIsomt0KYDo7Y="; - - buildPhase = '' - runHook preBuild - - go build -buildmode=c-shared -o libgomuks.so -tags goolm,noheic ./pkg/ffi - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - install -Dm0644 libgomuks.so -t $out/lib - - runHook postInstall - ''; -}) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index abf5dc5..58cd859 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -43,7 +43,6 @@ static void my_application_activate(GApplication* application) { } } #endif - gtk_widget_set_size_request(GTK_WIDGET(window), 250, -1); if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); diff --git a/nix/android.nix b/nix/android.nix new file mode 100644 index 0000000..f373968 --- /dev/null +++ b/nix/android.nix @@ -0,0 +1,20 @@ +{ + androidenv, +}: +androidenv.composeAndroidPackages { + toolsVersion = "26.1.1"; + platformToolsVersion = "36.0.1"; + buildToolsVersions = [ + "35.0.0" + "36.0.0" + ]; + cmakeVersions = [ "3.22.1" ]; + platformVersions = [ "36" ]; + abiVersions = [ + "armeabi-v7a" + "arm64-v8a" + ]; + includeNDK = true; + ndkVersions = [ "27.0.12077973" ]; + +} diff --git a/nix/fake-rustup.sh b/nix/fake-rustup.sh new file mode 100644 index 0000000..7884c05 --- /dev/null +++ b/nix/fake-rustup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Fake rustup for nix-managed Rust toolchains + +case "$1" in + run) + if [[ "$2" == "stable" ]]; then + shift 2 + if [[ $# -eq 0 ]]; then + echo "fake rustup: no command given" >&2 + exit 1 + fi + exec "$@" + exit 0 + fi + ;; + + toolchain) + if [[ "$2" == "list" ]]; then + echo "stable (default)" + exit 0 + fi + ;; + + target) + if [[ "$2" == "list" && "$3" == "--toolchain" && "$4" == "stable" && "$5" == "--installed" ]]; then + echo "x86_64-unknown-linux-gnu" + exit 0 + fi + ;; +esac + +echo "fake rustup: the command:" >&2 +echo " rustup $*" >&2 +echo "…is not mocked yet" >&2 +exit 1 \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index ef7fcd9..871bb6f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: analyzer_buffer - sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1" + sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.1.11" analyzer_plugin: dependency: transitive description: @@ -57,14 +57,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" - url: "https://pub.dev" - source: hosted - version: "1.6.5" async: dependency: transitive description: @@ -73,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + base58check: + dependency: transitive + description: + name: base58check + sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5" + url: "https://pub.dev" + source: hosted + version: "2.0.0" blurhash_dart: dependency: transitive description: @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -137,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.1" + canonical_json: + dependency: transitive + description: + name: canonical_json + sha256: d6be1dd66b420c6ac9f42e3693e09edf4ff6edfee26cb4c28c1c019fdb8c0c15 + url: "https://pub.dev" + source: hosted + version: "1.1.2" characters: dependency: transitive description: @@ -145,14 +161,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" checked_yaml: dependency: transitive description: @@ -185,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" + clipboard: + dependency: "direct main" + description: + name: clipboard + sha256: "619f4e9e946cfd637ac994f49af356bb590ab88b0c4aded03204ee566fd69d9e" + url: "https://pub.dev" + source: hosted + version: "3.0.8" clock: dependency: transitive description: @@ -193,22 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - code_assets: - dependency: "direct main" - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "4.11.0" collection: dependency: "direct main" description: @@ -242,7 +250,7 @@ packages: source: hosted version: "1.15.0" cross_cache: - dependency: "direct main" + dependency: transitive description: name: cross_cache sha256: "4983a16603cc99b0a14de6a772fa8ee4533411f46f3c423f1386fea7566049c5" @@ -293,10 +301,10 @@ packages: dependency: transitive description: name: custom_lint_visitor - sha256: e466d17856197cf9bce7ca03804d784fddab809db7bda787f3d2799ac89faadd + sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" url: "https://pub.dev" source: hosted - version: "1.0.0+9.0.0" + version: "1.0.0+8.4.0" dart_style: dependency: transitive description: @@ -337,40 +345,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - dynamic_polls: - dependency: "direct main" - description: - name: dynamic_polls - sha256: fba71ee6fb0ae8f3bebf7d07b3f2a79347d496956de88fb24d3daa32d47e0774 - url: "https://pub.dev" - source: hosted - version: "0.0.6" dynamic_system_colors: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "3b61760d5e0ac1229eefde5b61247947eede4110" - url: "https://github.com/hasali19/flutter_dynamic_system_colors" - source: git - version: "1.8.0" - emoji_text_field: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "0e90703a6e876939be70bd1816c49cf14474de61" - url: "https://github.com/Henry-Hiles/emoji_text_field" - source: git - version: "1.0.0" - encrypt: - dependency: transitive - description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + name: dynamic_system_colors + sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "1.8.0" fake_async: dependency: transitive description: @@ -388,21 +370,13 @@ packages: source: hosted version: "11.1.0" ffi: - dependency: "direct main" + dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.5" - ffigen: - dependency: "direct main" - description: - name: ffigen - sha256: b7803707faeec4ce3c1b0c2274906504b796e3b70ad573577e72333bd1c9b3ba - url: "https://pub.dev" - source: hosted - version: "20.1.1" + version: "2.1.4" file: dependency: transitive description: @@ -475,11 +449,12 @@ packages: flutter_chat_ui: dependency: "direct main" description: - name: flutter_chat_ui - sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f - url: "https://pub.dev" - source: hosted - version: "2.11.1" + path: "packages/flutter_chat_ui" + ref: HEAD + resolved-ref: "6cfbadbf364251dd3c6a986e20c9d97636ad3412" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git + version: "2.9.1" flutter_hooks: dependency: "direct main" description: @@ -499,19 +474,12 @@ packages: flutter_link_previewer: dependency: "direct main" description: - name: flutter_link_previewer - sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" - url: "https://pub.dev" - source: hosted - version: "4.2.0" - flutter_linkify: - dependency: "direct main" - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" + path: "packages/flutter_link_previewer" + ref: HEAD + resolved-ref: "6cfbadbf364251dd3c6a986e20c9d97636ad3412" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git + version: "4.1.2" flutter_lints: dependency: "direct dev" description: @@ -525,6 +493,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_math_fork: + dependency: transitive + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://pub.dev" + source: hosted + version: "0.7.4" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -537,10 +513,18 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" + sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.1.0" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_svg: dependency: "direct main" description: @@ -554,6 +538,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_vodozemac: + dependency: "direct main" + description: + name: flutter_vodozemac + sha256: "16d4b44dd338689441fe42a80d0184e5c864e9563823de9e7e6371620d2c0590" + url: "https://pub.dev" + source: hosted + version: "0.4.1" flutter_web_plugins: dependency: transitive description: flutter @@ -599,14 +591,23 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + flyer_chat_text_message: + dependency: "direct main" + description: + path: "packages/flyer_chat_text_message" + ref: HEAD + resolved-ref: "6cfbadbf364251dd3c6a986e20c9d97636ad3412" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git + version: "2.5.2" freezed: dependency: "direct dev" description: name: freezed - sha256: "03dd9b7423ff0e31b7e01b2204593e5e1ac5ee553b6ea9d8184dff4a26b9fb07" + sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.3" freezed_annotation: dependency: "direct main" description: @@ -623,14 +624,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - get_x_storage: - dependency: transitive - description: - name: get_x_storage - sha256: c9c65de2baa228783f46a55137538dc599a3c9b1834130cfd3b417ec3b643813 - url: "https://pub.dev" - source: hosted - version: "0.0.8" glob: dependency: transitive description: @@ -639,6 +632,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + gpt_markdown: + dependency: transitive + description: + name: gpt_markdown + sha256: "9b88dfaffea644070b648c204ca4a55745a49f4ad0b58ed0ab70913ad593c7a1" + url: "https://pub.dev" + source: hosted + version: "1.1.5" graphs: dependency: transitive description: @@ -647,22 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - hooks: - dependency: "direct main" - description: - name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" - url: "https://pub.dev" - source: hosted - version: "1.0.0" hooks_riverpod: dependency: "direct main" description: name: hooks_riverpod - sha256: "08527ec06aaef75e4b78694e045ef0cd8346594eaf9cc18b0f866398b07b93b1" + sha256: b880efcd17757af0aa242e5dceac2fb781a014c22a32435a5daa8f17e9d5d8a9 url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.1.0" html: dependency: transitive description: @@ -671,8 +664,16 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -699,10 +700,10 @@ packages: dependency: transitive description: name: idb_shim - sha256: b26b2ad126be411d0072d1dfc4d97ebe02121a863e4eadc635b511b9bc138489 + sha256: "071f3b05032fa62e60ca15db9939f8afbaf403b37e67747ac88f858c3e999228" url: "https://pub.dev" source: hosted - version: "2.7.1+2" + version: "2.6.7+1" image: dependency: transitive description: @@ -815,6 +816,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.3" + just_throttle_it: + dependency: transitive + description: + name: just_throttle_it + sha256: af2d0c1e5c7f4e0bef79a55edf3d74c180908253f89203467bc432730f5fac5b + url: "https://pub.dev" + source: hosted + version: "3.0.1" leak_tracker: dependency: transitive description: @@ -839,14 +848,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - linkify: - dependency: transitive - description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted - version: "5.0.0" lints: dependency: transitive description: @@ -863,6 +864,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" matcher: dependency: transitive description: @@ -879,14 +888,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + matrix: + dependency: "direct main" + description: + name: matrix + sha256: fb116ee89f6871441f22f76a988db15cfcfb6dfac97e3e2d654c240080015707 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + mention_tag_text_field: + dependency: "direct main" + description: + name: mention_tag_text_field + sha256: ba7b9d8003e0f340a65c6dcdb7770f4340f653ae1612a9e31e11d12f7f1dd80f + url: "https://pub.dev" + source: hosted + version: "0.0.9" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1007,14 +1032,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" pool: dependency: transitive description: @@ -1063,38 +1080,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - quiver: + random_string: dependency: transitive description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + name: random_string + sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "2.3.1" riverpod: dependency: transitive description: name: riverpod - sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" + sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.1.0" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66 + sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" url: "https://pub.dev" source: hosted - version: "1.0.0-dev.9" + version: "1.0.0-dev.8" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f" + sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.0" rxdart: dependency: transitive description: @@ -1147,18 +1164,34 @@ packages: dependency: transitive description: name: scrollview_observer - sha256: "6e40ced415145c449a691d892157a3b854b751f024aed20d9aebda04c21444a3" + sha256: c2f713509f18f88f637b2084b47a90c91fb1ef066d5d82d2cf3194d8509dc6ab url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" + sdp_transform: + dependency: transitive + description: + name: sdp_transform + sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45" + url: "https://pub.dev" + source: hosted + version: "0.3.2" sembast: dependency: transitive description: name: sembast - sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505" + sha256: c8063c3146c3c8d5f5b04230de7682c768440a575fbda2634f14d22f263197c3 url: "https://pub.dev" source: hosted - version: "3.8.6" + version: "3.8.5+2" + sembast_web: + dependency: transitive + description: + name: sembast_web + sha256: "0362c7c241ad6546d3e27b4cfffaae505e5a9661e238dbcdd176756cc960fe7a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" shared_preferences: dependency: "direct main" description: @@ -1247,11 +1280,75 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_secure_storage: + dependency: "direct main" + description: + name: simple_secure_storage + sha256: ca823a355bb7bb0e9b969876508e7d3a5dc0d1fb2dcb681c85b6e315f1e876e9 + url: "https://pub.dev" + source: hosted + version: "0.3.7" + simple_secure_storage_android: + dependency: transitive + description: + name: simple_secure_storage_android + sha256: "50fb27267755843af039da116d0e545f313ae329ef8838101880802259e0f741" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + simple_secure_storage_darwin: + dependency: transitive + description: + name: simple_secure_storage_darwin + sha256: "8bd2ffcc62b478957ce20046bb96618b91a11e74af5d9fe2b4b229117bad18a7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + simple_secure_storage_linux: + dependency: transitive + description: + name: simple_secure_storage_linux + sha256: a7b7dccfaf496c27f882c26634ac083f2f545c0a4ca0818534c6261205a83686 + url: "https://pub.dev" + source: hosted + version: "0.2.5" + simple_secure_storage_platform_interface: + dependency: transitive + description: + name: simple_secure_storage_platform_interface + sha256: "04fd4ce4c2b97c01a12eba46f51e3075a793d11f13340d06a64eb9b45a463ca5" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + simple_secure_storage_web: + dependency: transitive + description: + name: simple_secure_storage_web + sha256: "63a3474a9931ab2587e01d22e7e95c0b7cc31338c0fafed5db9d1d798d1d3e0e" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + simple_secure_storage_windows: + dependency: transitive + description: + name: simple_secure_storage_windows + sha256: cf31d2a97c26cf854aeb3c9774cd253f6600fb3fdfc6d807d480afae678cef10 + url: "https://pub.dev" + source: hosted + version: "0.3.2" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + slugify: + dependency: transitive + description: + name: slugify + sha256: b272501565cb28050cac2d96b7bf28a2d24c8dae359280361d124f3093d337c3 + url: "https://pub.dev" + source: hosted + version: "2.0.0" source_gen: dependency: "direct overridden" description: @@ -1292,6 +1389,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: "9faa2fedc5385ef238ce772589f7718c24cdddd27419b609bb9c6f703ea27988" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" stack_trace: dependency: transitive description: @@ -1352,26 +1473,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" thumbhash: dependency: transitive description: @@ -1380,14 +1501,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0+1" - timeago: - dependency: "direct main" + tuple: + dependency: transitive description: - name: timeago - sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 url: "https://pub.dev" source: hosted - version: "3.7.1" + version: "2.0.2" typed_data: dependency: transitive description: @@ -1396,22 +1517,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_html: + unorm_dart: dependency: transitive description: - name: universal_html - sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + name: unorm_dart + sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b" url: "https://pub.dev" source: hosted - version: "2.3.0" - universal_io: - dependency: transitive - description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 - url: "https://pub.dev" - source: hosted - version: "2.3.1" + version: "0.2.0" url_launcher: dependency: "direct main" description: @@ -1524,14 +1637,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + vodozemac: + dependency: "direct main" + description: + name: vodozemac + sha256: "39144e20740807731871c9248d811ed5a037b21d0aa9ffcfa630954de74139d9" + url: "https://pub.dev" + source: hosted + version: "0.4.0" watcher: dependency: transitive description: name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.0" web: dependency: transitive description: @@ -1556,6 +1677,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webcrypto: + dependency: transitive + description: + name: webcrypto + sha256: "6b43001c4110856ff7fa5e5e65e7b2d44bec1d8b54a4d84d5fa2c7622267c5c1" + url: "https://pub.dev" + source: hosted + version: "0.6.0" webkit_inspection_protocol: dependency: transitive description: @@ -1564,6 +1693,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233" + url: "https://pub.dev" + source: hosted + version: "1.3.0" win32: dependency: transitive description: @@ -1580,6 +1717,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + window_size: + dependency: "direct main" + description: + path: "plugins/window_size" + ref: HEAD + resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601 + url: "https://github.com/google/flutter-desktop-embedding" + source: git + version: "0.1.0" xdg_directories: dependency: transitive description: @@ -1613,5 +1759,5 @@ packages: source: hosted version: "2.2.3" sdks: - dart: ">=3.10.4 <4.0.0" + dart: ">=3.9.2 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7ecefa1..9551407 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,15 +14,15 @@ environment: dependency_overrides: analyzer: ^8.4.0 source_gen: ^4.0.2 - flutter_hooks: ^0.21.2 dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - flutter_riverpod: ^3.3.1 - hooks_riverpod: ^3.3.1 + flutter_hooks: ^0.21.2 + flutter_riverpod: ^3.0.3 + hooks_riverpod: ^3.0.3 intl: ^0.20.1 fast_immutable_collections: ^11.0.0 path_provider: ^2.1.3 @@ -31,43 +31,49 @@ dependencies: image_picker: ^1.1.2 file_picker: ^10.3.3 path: ^1.9.0 - dynamic_system_colors: - git: - url: https://github.com/hasali19/flutter_dynamic_system_colors + dynamic_system_colors: ^1.8.0 collection: ^1.19.1 window_manager: ^0.5.1 + window_size: + git: + url: https://github.com/google/flutter-desktop-embedding + path: plugins/window_size flutter_chat_core: ^2.0.0 flyer_chat_image_message: ^2.2.2 flyer_chat_system_message: ^2.1.13 flyer_chat_file_message: ^2.3.1 - flutter_chat_ui: ^2.11.1 - flutter_link_previewer: ^4.2.0 + flyer_chat_text_message: + git: + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flyer_chat_text_message + flutter_chat_ui: + git: + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flutter_chat_ui + flutter_link_previewer: + git: + url: https://github.com/Henry-Hiles/flutter_chat_ui + path: packages/flutter_link_previewer + matrix: ^4.1.0 + sqflite_common_ffi: ^2.3.6 color_hash: ^1.0.1 + flutter_vodozemac: ^0.4.1 flutter_widget_from_html_core: ^0.17.0 flutter_svg: ^2.2.2 + simple_secure_storage: ^0.3.6 json_annotation: ^4.9.0 + vodozemac: ^0.4.0 + clipboard: ^3.0.8 shared_preferences: ^2.5.3 + mention_tag_text_field: ^0.0.9 fluttertagger: ^2.3.1 - dynamic_polls: ^0.0.6 - flutter_hooks: ^0.21.3+1 - cross_cache: ^1.1.0 - ffi: ^2.1.5 - hooks: ^1.0.0 - code_assets: ^1.0.0 - ffigen: ^20.1.1 - timeago: ^3.7.1 - http: ^1.6.0 - flutter_linkify: ^6.0.0 - emoji_text_field: - git: - url: https://github.com/Henry-Hiles/emoji_text_field dev_dependencies: build_runner: ^2.4.11 custom_lint: ^0.8.0 flutter_lints: ^6.0.0 freezed: ^3.2.3 - riverpod_lint: ^3.1.3 + riverpod_lint: ^3.0.3 flutter_launcher_icons: ^0.14.1 json_serializable: ^6.11.1 @@ -75,8 +81,6 @@ flutter_launcher_icons: ios: true android: true image_path: assets/icon.png - adaptive_icon_background: assets/background.png - adaptive_icon_foreground: assets/smallerForeground.png - remove_alpha_ios: true - windows: - generate: true \ No newline at end of file + adaptive_icon_background: "#000000" + adaptive_icon_foreground: assets/foreground.png + remove_alpha_ios: true \ No newline at end of file diff --git a/scripts/generate.dart b/scripts/generate.dart deleted file mode 100644 index 446a469..0000000 --- a/scripts/generate.dart +++ /dev/null @@ -1,26 +0,0 @@ -import "dart:io"; -import "package:ffigen/ffigen.dart"; -import "package:path/path.dart"; - -void main(List args) async { - final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks")); - - print("Generating FFI Bindings..."); - - final libclangPath = Platform.environment["LIBCLANG_PATH"]; - FfiGenerator( - output: Output( - dartFile: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), - ), - headers: Headers( - entryPoints: [File(join(repoDir.path, "pkg", "ffi", "gomuksffi.h")).uri], - compilerOptions: ["--no-warnings"], - ), - functions: Functions.includeAll, - ).generate( - libclangDylib: libclangPath == null - ? null - : Uri.file(join(libclangPath, "libclang.so")), - ); - print("Done!"); -} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bde1c28..9d7af86 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,8 +9,11 @@ #include #include #include +#include #include +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { DynamicColorPluginCApiRegisterWithRegistrar( @@ -19,8 +22,14 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + SimpleSecureStorageWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SimpleSecureStorageWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WebcryptoPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WebcryptoPlugin")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); + WindowSizePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowSizePlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7b6b425..dcf3309 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,11 +6,15 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_windows screen_retriever_windows + simple_secure_storage_windows url_launcher_windows + webcrypto window_manager + window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_vodozemac ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/installer.iss b/windows/installer.iss deleted file mode 100644 index c5004c3..0000000 --- a/windows/installer.iss +++ /dev/null @@ -1,17 +0,0 @@ -[Setup] -AppName=Nexus -AppVersion=1.0.0 -DefaultDirName={pf}\Nexus -DefaultGroupName=Nexus -OutputDir=dist -OutputBaseFilename=Nexus-Setup -Compression=lzma -SolidCompression=yes -ArchitecturesInstallIn64BitMode=x64 - -[Files] -Source: "..\build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion - -[Icons] -Name: "{group}\Nexus"; Filename: "{app}\nexus.exe" -Name: "{commondesktop}\Nexus"; Filename: "{app}\nexus.exe" \ No newline at end of file diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 3583d23..24405eb 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -89,11 +89,11 @@ BEGIN BEGIN BLOCK "040904e4" BEGIN - VALUE "CompanyName", "nexus.federated.Nexus" "\0" + VALUE "CompanyName", "nexus.federated.nexus" "\0" VALUE "FileDescription", "nexus" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "nexus" "\0" - VALUE "LegalCopyright", "Copyright (C) 2025 nexus.federated.Nexus. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 nexus.federated.nexus. All rights reserved." "\0" VALUE "OriginalFilename", "nexus.exe" "\0" VALUE "ProductName", "nexus" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index e3c83c9..c04e20c 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ