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 index c07f0ad..c8099d1 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,57 +1,46 @@ -name: "Build EXE" +name: "Build Windows Version" on: - push: - branches: ["main"] - tags: ["*"] workflow_dispatch: jobs: - build-exe: - runs-on: windows-latest + build-windows: + runs-on: "windows-latest" steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive + - name: "Checkout repository" + uses: "actions/checkout@v4" - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: 3.41.5 + - name: "Set up Flutter" + uses: "subosito/flutter-action@v2" - - name: Set up Go - uses: actions/setup-go@v6 + - name: "Set up Rust" + uses: "dtolnay/rust-toolchain@stable" with: - go-version-file: gomuks/go.mod + targets: "x86_64-pc-windows-msvc" - - name: Go build + - name: "Install Flutter dependencies" + run: flutter pub get + + - name: "Run build_runner & build Windows EXE" 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 pub run build_runner build --delete-conflicting-outputs flutter build windows --release - - name: Upload exe zip - uses: actions/upload-artifact@v6 + - name: "Upload exe zip" + uses: "actions/upload-artifact@v4" with: - name: windows-portable - path: build/windows/x64/runner/Release/ + name: "windows-portable" + path: "build/windows/x64/runner/Release/" - - name: Install Inno Setup + - name: "Install Inno Setup" run: choco install innosetup -y - - name: Build Inno Setup installer + - name: "Build Inno Setup installer" run: iscc windows/installer.iss - - name: Upload installer artifact - uses: actions/upload-artifact@v6 + - name: "Upload installer artifact" + uses: "actions/upload-artifact@v4" with: - name: windows-installer - path: windows/dist/Nexus-Setup.exe \ No newline at end of file + name: "windows-installer" + path: "windows/dist/Nexus-Setup.exe" diff --git a/.gitignore b/.gitignore index 2bec583..757aeea 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,7 @@ key.properties # Generated Files *.g.dart *.freezed.dart +/src/ # 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..25ea52b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,11 +2,8 @@ "cSpell.words": [ "Appbar", "Displayname", - "fluttertagger", - "Gomuks", "Homeserver", - "localpart", - "muks", - "prefs" + "prefs", + "vodozemac" ] } 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 50060d0..62d2b75 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,184 +15,120 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [x] New logo -- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Nexus/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 - - [ ] Align your message bubbles to left or right - - [ ] 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 +- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 +- [ ] Platform Support + - [x] Linux + - [x] Windows + - [ ] 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 + - [x] Mark as read button on rooms and spaces + - [ ] Searching + - [ ] Creating (Rooms, Spaces, and DMs) + - [x] Joining + - [x] Using a text/uri/link + - [x] Plain text + - [x] `matrix:` Uri + - [ ] Matrix.to link: I just need to fix my regex + - [ ] From space + - [ ] Exploring + - [x] Leaving + - [x] Subspaces +- [x] Messages + - [x] Encryption + - [x] Restoring crypto identity from passphrase/key or verification + - [x] Sending + - [x] Plain text + - [x] HTML/Markdown + - [x] Replies + - [ ] Attachments + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Custom emojis/stickers + - [ ] GIFs using Giphy + - [x] Recieving + - [x] Plain text + - [x] HTML + - [x] Replies + - [x] Viewing + - [ ] Jump to original message + - [x] Edits + - [x] Attachments + - [x] Blurhashing + - [ ] Downloading attachments + - [x] Opening attachments in their own view + - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) + - [x] Matrix URIs + - [x] Matrix.to links + - [x] Custom emojis/stickers + - [x] History loading + - [x] Backwards + - [ ] Forwards + - [x] Editing + - [x] Deleting +- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl +- [ ] 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 + - [ ] Show media by default + - [ ] 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: +First, clone and open the repo: -- [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/Nexus/nexus` - -## Build it yourself +```sh +git clone https://git.federated.nexus/Henry-Hiles/nexus +cd nexus +``` ### 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. +- 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`). -#### Windows +#### Windows / MacOS -You will need: +I don't really know. You will need Flutter and Rust, and otherwise I guess just keep installing stuff until there aren't any errors. -- Flutter -- Android SDK + NDK -- Git -- Go -- Visual Studio 2022 (Desktop development with C++) -- [MSYS2/MinGW-w64 GCC](https://www.msys2.org/) (for CGO) -- [LLVM/Clang + libclang](https://clang.llvm.org/get_started.html) (for `ffigen`) - -On Windows, make sure these are available in your shell `PATH`: - -- `C:\msys64\ucrt64\bin` (or your MinGW bin path containing `x86_64-w64-mingw32-gcc.exe`) -- `C:\Program Files\LLVM\bin` (contains `clang.exe` and `libclang.dll`) - -For `dart scripts/generate.dart`, you may also need: - -```powershell -$env:CPATH = "C:\msys64\ucrt64\include" -``` - -#### MacOS - -Similar prerequisites apply (Flutter, Git, Go, C toolchain, LLVM/libclang), but exact setup has not been fully documented yet. - -### Clone repo - -First, clone and open the repo: - -```sh -git clone --recurse-submodules https://git.federated.nexus/Nexus/nexus -cd nexus -``` - -### Set up Flutter +### Get dependencies: @@ -200,12 +136,6 @@ Get dependencies: flutter pub get ``` -Generate Gomuks bindings: - -```sh -dart scripts/generate.dart -``` - Build generated files, and watch for new changes: ```sh @@ -221,8 +151,3 @@ flutter run ## Community Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. - -# Credits - -Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus! -Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus! 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/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index e97fe0e..80efd04 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 4e9192d..b02e5ef 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index f18b718..54aed69 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 2f6a559..eb2221d 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 0118074..c5ac464 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/background.png b/assets/background.png deleted file mode 100644 index 9f1d8e7..0000000 Binary files a/assets/background.png and /dev/null differ diff --git a/assets/background.svg b/assets/background.svg deleted file mode 100644 index 749e03a..0000000 --- a/assets/background.svg +++ /dev/null @@ -1,257 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/foreground.png b/assets/foreground.png index a98eb11..4249989 100644 Binary files a/assets/foreground.png and b/assets/foreground.png differ diff --git a/assets/foreground.svg b/assets/foreground.svg index 9aad561..4f2f2b2 100644 --- a/assets/foreground.svg +++ b/assets/foreground.svg @@ -1,19 +1,20 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + inkscape:current-layer="layer1" /> diff --git a/assets/icon.png b/assets/icon.png index d6d4906..04b75cb 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/assets/icon.svg b/assets/icon.svg index b36fa26..0effd9a 100644 --- a/assets/icon.svg +++ b/assets/icon.svg @@ -1,22 +1,21 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="stop11" /> diff --git a/assets/mobile.png b/assets/mobile.png deleted file mode 100644 index 6b1b81c..0000000 Binary files a/assets/mobile.png and /dev/null differ diff --git a/assets/mobile.svg b/assets/mobile.svg deleted file mode 100644 index 7ca0a7d..0000000 --- a/assets/mobile.svg +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/monochrome.png b/assets/monochrome.png deleted file mode 100644 index 941c706..0000000 Binary files a/assets/monochrome.png and /dev/null differ diff --git a/assets/monochrome.svg b/assets/monochrome.svg deleted file mode 100644 index a86f36e..0000000 --- a/assets/monochrome.svg +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/devtools_options.yaml b/devtools_options.yaml deleted file mode 100644 index fa0b357..0000000 --- a/devtools_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: This file stores settings for Dart & Flutter DevTools. -documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -extensions: diff --git a/flake.lock b/flake.lock index 8070c6c..7826732 100644 --- a/flake.lock +++ b/flake.lock @@ -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": 1767640445, + "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=", + "owner": "nixos", "repo": "nixpkgs", - "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda", + "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5", "type": "github" }, "original": { - "owner": "NixOS", + "owner": "nixos", "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" @@ -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..0e5e7c8 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 = @@ -31,46 +29,67 @@ ... }: + let + src = ./.; + + # from https://discourse.nixos.org/t/is-there-a-way-to-read-a-yaml-file-and-get-back-a-set/18385/5 + importYAML = + file: + lib.importJSON ( + pkgs.runCommand "converted-yaml.json" { } ''${pkgs.yj}/bin/yj < "${file}" > "$out"'' + ); + + package = importYAML "${src}/pubspec.yaml"; + + usedFlutter = (pkgs.flutter.override { extraPkgConfigPackages = [ pkgs.libsecret ]; }); + + buildInputs = [ pkgs.sqlite ]; + in { _module.args.pkgs = import nixpkgs { inherit system; config = { + permittedInsecurePackages = [ "olm-3.2.16" ]; android_sdk.accept_license = true; allowUnfree = true; }; }; - packages = - let - default = pkgs.callPackage ./linux/nix/pkg { - src = self; - }; - in - { - inherit default; + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + olm + git + clang + usedFlutter - 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" ]; - }; - }; + (pkgs.writeShellScriptBin "rustup" (builtins.readFile ./nix/fake-rustup.sh)) + ]; - gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix { - src = self; - }; + env = { + LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}:./build/native_assets/linux"; + CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; + }; + }; + + packages.default = usedFlutter.buildFlutterApplication { + inherit src buildInputs; + pname = package.name; + version = package.version; + + pubspecLock = importYAML "${src}/pubspec.lock"; + gitHashes = { + flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; + flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; + flyer_chat_text_message = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk="; + window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; }; - devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { }; + patchPhase = /* sh */ '' + patchShebangs ./scripts/generate.sh + ./scripts/generate.sh # note: this doesn't quit the build on fail + ''; + }; }; }; } 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 index 165e613..4cb2f91 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -3,12 +3,11 @@ 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; + final buildDir = input.packageRoot.resolve("src/"); + if (await File(buildDir.resolve("lock").toFilePath()).exists()) return; + final targetOS = input.config.code.targetOS; String libFileName; - Map env = {}; switch (targetOS) { case OS.linux: libFileName = "libgomuks.so"; @@ -19,57 +18,23 @@ Future main(List args) => build(args, (input, output) async { 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/"); + final gomuksBuildDir = buildDir.resolve("gomuks/"); + final libFile = gomuksBuildDir.resolve(libFileName); - if (!(await File.fromUri(libFile).exists())) { - final buildDir = input.packageRoot.resolve("build/"); - libFile = buildDir.resolve("${targetArch.name}/$libFileName"); + print("Building Gomuks shared library $libFileName from source..."); + final result = await Process.run("go", [ + "build", + "-o", + libFile.path, + "-buildmode=c-shared", + ], workingDirectory: gomuksBuildDir.resolve("source/pkg/ffi/").toFilePath()); - // 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}", - ); - } + if (result.exitCode != 0) { + throw Exception("Failed to build Gomuks shared library\n${result.stderr}"); } final generatedFile = "src/third_party/gomuks.g.dart"; @@ -87,43 +52,3 @@ Future main(List args) => build(args, (input, output) async { ..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/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 0d531c4..2b21522 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index da4acee..8471cd6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index a3cfb1d..c145b15 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index adbdcd5..5da5679 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index fee4302..cd2b74f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 4d21624..68cbdbf 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 3e7a859..306efe8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index a3cfb1d..c145b15 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index c11ce99..959cc28 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 25f2b47..d86b69c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 8f79bb9..3a5c49b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index c48dec6..e563327 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 99d44e8..30ae8c6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 6f987f0..2fb68c4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 25f2b47..d86b69c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index fcf969a..151862a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 1e0defa..c5ca065 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 3366fb5..a5880bd 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index e280112..6ea8156 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index efda04b..657cf77 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 3774574..87d1ce7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ 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/client_controller.dart b/lib/controllers/client_controller.dart index cc68871..abfdb72 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,21 +1,15 @@ import "dart:developer"; import "dart:ffi"; -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"; @@ -28,28 +22,17 @@ 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:flutter_riverpod/flutter_riverpod.dart"; -import "package:path_provider/path_provider.dart"; 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(); - } - - final handle = GomuksInit(root); + final handle = await Isolate.run(GomuksInit); final callable = NativeCallable< @@ -75,43 +58,19 @@ class ClientController extends AsyncNotifier { .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); - } + if (syncData.clearState) ref.invalidate(roomProvider); 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) @@ -122,13 +81,15 @@ class ClientController extends AsyncNotifier { // .watch(SyncStatusController.provider.notifier) // .set(SyncStatus.fromJson(decodedMuksEvent)); break; + case "typing": + //TODO: IMPL + break; default: debugPrint("Unhandled event: $muksEventType"); } debugPrint("Finished handling $muksEventType..."); } catch (error, stackTrace) { debugger(); - showError(error, stackTrace); debugPrintStack(stackTrace: stackTrace, label: error.toString()); } }); @@ -143,9 +104,9 @@ class ClientController extends AsyncNotifier { } Future _sendCommand( - String command, [ - Map data = const {}, - ]) async { + String command, + Map data, + ) async { final bufferPointer = data.toGomuksBufferPtr(); final handle = await future; final response = await Isolate.run( @@ -166,18 +127,15 @@ class ClientController extends AsyncNotifier { Future redactEvent(RedactEventRequest report) => _sendCommand("redact_event", report.toJson()); - Future sendMessage(SendMessageRequest request) async => - Event.fromJson(await _sendCommand("send_message", request.toJson())); + Future sendMessage(SendMessageRequest request) => + _sendCommand("send_message", request.toJson()); - Future sendEvent(SendEventRequest request) async => - Event.fromJson(await _sendCommand("send_event", request.toJson())); - - Future verify(String recoveryKey) async { + Future verify(String recoveryKey) async { try { await _sendCommand("verify", {"recovery_key": recoveryKey}); - return null; + return true; } catch (error) { - return error.toString(); + return false; } } @@ -186,29 +144,15 @@ class ClientController extends AsyncNotifier { 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(); + final response = + (await _sendCommand("get_room_state", request.toJson())) as List; + return response.map((event) => Event.fromJson(event)).toIList(); } Future?> getRelatedEvents( @@ -220,12 +164,8 @@ class ClientController extends AsyncNotifier { } 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); } @@ -235,38 +175,31 @@ class ClientController extends AsyncNotifier { 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 reportEvent(ReportRequest report) => + _sendCommand("report_event", report.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; - + if (room.events.isEmpty || room.metadata == null) return; await _sendCommand("mark_read", { - "room_id": room.metadata!.id, + "room_id": room.metadata?.id, "receipt_type": "m.read", - "event_id": event.eventId, + "event_id": room.events.last.eventId, }); } - Future login(LoginRequest login) async { + Future login(LoginRequest login) async { try { await _sendCommand("login", login.toJson()); - return null; + return true; } catch (error) { - return error.toString(); + return false; } } Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { - "user_id": "@fake-user:${homeserver.host}", + "user_id": "@fakeuser:${homeserver.host}", }); return response["m.homeserver"]?["base_url"]; } catch (error) { diff --git a/lib/controllers/emoji_controller.dart b/lib/controllers/emoji_controller.dart deleted file mode 100644 index 358f98b..0000000 --- a/lib/controllers/emoji_controller.dart +++ /dev/null @@ -1,88 +0,0 @@ -import "dart:convert"; -import "package:emoji_text_field/models/emoji_category.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:http/http.dart"; -import "package:nexus/models/emoji.dart"; - -typedef EmojiTuple = (IMap, IMap>); - -class EmojiController extends AsyncNotifier { - @override - Future build() async { - final response = await get( - Uri.https( - "github.com", - "github/gemoji/raw/refs/heads/master/db/emoji.json", - ), - ); - - if (response.statusCode != 200) { - throw Exception("Failed to load emoji data"); - } - - final data = json.decode(response.body); - - final entries = (data as List) - .cast>() - .map(Emoji.fromJson) - .toIList(); - - final categoryMap = entries.fold>>( - const IMap.empty(), - (acc, entry) => acc.update( - entry.category, - (list) => list.add(entry.emoji), - ifAbsent: () => IList([entry.emoji]), - ), - ); - - final keywordMap = entries.fold>>( - const IMap.empty(), - (acc, entry) => acc.add( - entry.emoji, - IList([...entry.tags, ...entry.aliases, entry.description]), - ), - ); - - final customCategories = IMap.fromEntries( - categoryMap.entries.map( - (entry) => MapEntry( - entry.key, - EmojiCategory( - name: entry.key, - icon: switch (entry.key) { - "Smileys & Emotion" => Icons.emoji_emotions, - "People & Body" => Icons.emoji_people, - "Animals & Nature" => Icons.emoji_nature, - "Food & Drink" => Icons.emoji_food_beverage, - "Travel & Places" => Icons.travel_explore, - "Activities" => Icons.sports_soccer, - "Objects" => Icons.emoji_objects, - "Symbols" => Icons.emoji_symbols, - "Flags" => Icons.emoji_flags, - _ => Icons.category, - }, - emojis: entry.value.toList(growable: false), - ), - ), - ), - ); - - final customKeywords = IMap( - Map.fromEntries( - keywordMap.entries.map( - (e) => MapEntry(e.key, e.value.toList(growable: false)), - ), - ), - ); - - return (customCategories, customKeywords); - } - - static final provider = - AsyncNotifierProvider.autoDispose( - EmojiController.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/header_controller.dart b/lib/controllers/header_controller.dart index 295cf04..ead8f0d 100644 --- a/lib/controllers/header_controller.dart +++ b/lib/controllers/header_controller.dart @@ -1,16 +1,23 @@ +import "dart:ffi"; +import "dart:isolate"; +import "package:ffi/ffi.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/src/third_party/gomuks.g.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"}; + final handle = await ref.watch(ClientController.provider.future); + final info = await Isolate.run(() => GomuksGetAccountInfo(handle)); + final headers = { + if (info.access_token != nullptr) + "authorization": + "Bearer ${info.access_token.cast().toDartString()}", + }; + + await Isolate.run(() => GomuksFreeAccountInfo(info)); + return headers; } static final provider = 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..2a250a2 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,52 +1,27 @@ +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_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:nexus/models/event.dart"; +import "package:nexus/models/room.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(); + Future> build() async => + (room.state["m.room.member"]?.values ?? []) + .map( + (eventRowId) => room.events.firstWhereOrNull( + (event) => event.rowId == eventRowId, + ), + ) + .nonNulls + .where((member) => member.content["membership"] == "join") + .toIList(); - 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(); - } - - static final provider = - AsyncNotifierProvider>( + static final provider = AsyncNotifierProvider.family + .autoDispose, Room>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index c65d18d..b695ec1 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -1,11 +1,11 @@ -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/controllers/profile_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/configs/message_config.dart"; +import "package:nexus/models/message_config.dart"; +import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_related_events_request.dart"; class MessageController extends AsyncNotifier { @@ -14,197 +14,172 @@ class MessageController extends AsyncNotifier { @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) { + if (config.event.relationType == "m.replace" && !config.includeEdits) { return null; } + final client = ref.watch(ClientController.provider.notifier); + + final newEvents = await client.getRelatedEvents( + GetRelatedEventsRequest( + roomId: config.event.roomId, + eventId: config.event.eventId, + relationType: "m.replace", + ), + ); + if (!ref.mounted) return null; + final event = newEvents?.lastOrNull ?? config.event; + + final replyId = + config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"]; + final replyEvent = replyId == null + ? null + : await client.getEvent( + GetEventRequest(roomId: config.event.roomId, eventId: replyId), + ); + + if (!ref.mounted) return null; + + final author = await ref.read( + ProfileController.provider(event.authorId).future, + ); + if (!ref.mounted) return null; + + final content = (event.decrypted ?? event.content); + final type = (config.event.decryptedType ?? config.event.type); + final newContent = content["m.new_content"] as Map?; + final metadata = { + "timelineId": event.timelineRowId, + "formatted": + newContent?["formatted_body"] ?? + newContent?["body"] ?? + content["formatted_body"] ?? + content["body"] ?? + "", + if (replyEvent != null) + "reply": await ref.read( + MessageController.provider( + MessageConfig(event: replyEvent, mustBeText: true), + ).future, + ), + "body": newContent?["body"] ?? content["body"], + "eventType": type, + "avatarUrl": author.avatarUrl, + "displayName": author.displayName ?? event.authorId, + "txnId": config.event.transactionId, + }; + + if (!ref.mounted) return null; + + final editedAt = event.relationType == "m.replace" ? event.timestamp : null; + + if ((event.redactedBy != null && !config.mustBeText) || + (!config.includeEdits && (config.event.relationType == "m.replace"))) { + return null; + } + + // TODO: Use server-generated preview if enabled + + // final match = Uri.tryParse( + // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", + // ); + + final asText = + Message.text( + metadata: metadata, + id: config.event.eventId, + authorId: event.authorId, + text: config.event.redactedBy == null + ? content["body"] ?? "" + : "This message has been deleted...", + replyToMessageId: replyId, + deliveredAt: config.event.timestamp, + editedAt: editedAt, + ) + as TextMessage; + + if (config.mustBeText) return asText; + + final homeserver = ref.read(ClientStateController.provider)?.homeserverUrl; + final source = homeserver == null || content["url"] == null + ? "null" + : Uri.parse(content["url"]).mxcToHttps(homeserver).toString(); + + return switch (type) { + "m.room.encrypted" => asText.copyWith( + text: "Unable to decrypt message.", + metadata: {...metadata, "formatted": "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"]) { + ("m.sticker" || "m.image") => Message.image( + id: config.event.eventId, + metadata: metadata, + authorId: event.authorId, + text: event.localContent?.sanitizedHtml, + source: source, + replyToMessageId: replyId, + deliveredAt: config.event.timestamp, + blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"], + ), + "m.audio" => Message.audio( + id: config.event.eventId, + metadata: metadata, + authorId: event.authorId, + text: content["body"], + replyToMessageId: replyId, + source: source, + deliveredAt: config.event.timestamp, + // TODO: See if we can figure out duration + duration: Duration(hours: 1), + ), + "m.file" => Message.file( + name: content["filename"].toString(), + metadata: metadata, + id: config.event.eventId, + authorId: event.authorId, + source: source, + replyToMessageId: replyId, + deliveredAt: config.event.timestamp, + ), + _ => asText, + }, + "m.room.member" => Message.system( + metadata: metadata, + id: config.event.eventId, + authorId: event.authorId, + deliveredAt: config.event.timestamp, + text: + "${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) { + "invite" => "was invited to", + "join" => "joined", + "leave" => "left", + "knock" => "asked to join", + "ban" => "was banned from", + _ => "did something relating to", + }} the room.", + ), + "m.room.redaction" => null, + _ => + // Turn this on for debugging purposes + false + // ignore: dead_code + ? Message.unsupported( + metadata: metadata, + id: config.event.eventId, + authorId: event.authorId, + replyToMessageId: replyId, + ) + : null, + }; } static final provider = AsyncNotifierProvider.family diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart index 28885fb..3edc8ab 100644 --- a/lib/controllers/messages_controller.dart +++ b/lib/controllers/messages_controller.dart @@ -2,26 +2,24 @@ 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"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/message_config.dart"; class MessagesController extends AsyncNotifier> { - final MessagesConfig config; - MessagesController(this.config); + final IList events; + MessagesController(this.events); @override Future> build() async => (await Future.wait( - config.events.map( + events.map( (event) => ref.watch( - MessageController.provider( - MessageConfig(event: event, room: config.room), - ).future, + MessageController.provider(MessageConfig(event: event)).future, ), ), )).nonNulls.toIList(); static final provider = AsyncNotifierProvider.family - .autoDispose, MessagesConfig>( + .autoDispose, IList>( MessagesController.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 index 120d4e4..e825593 100644 --- a/lib/controllers/profile_controller.dart +++ b/lib/controllers/profile_controller.dart @@ -7,11 +7,11 @@ class ProfileController extends AsyncNotifier { ProfileController(this.userId); @override - Future build() { - final client = ref.watch(ClientController.provider.notifier); - return client.getProfile(userId); - } + Future build() => + ref.watch(ClientController.provider.notifier).getProfile(userId); - static final provider = AsyncNotifierProvider.autoDispose - .family(ProfileController.new); + static final provider = + AsyncNotifierProvider.family( + ProfileController.new, + ); } diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index fa32bf8..091cdc9 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,7 +1,7 @@ -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"; @@ -10,32 +10,104 @@ 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/message_config.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: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 { +class RoomChatController extends AsyncNotifier { final String roomId; RoomChatController(this.roomId); @override - Future build() async { + Future build() async { final client = ref.watch(ClientController.provider.notifier); - var room = ref.read(RoomsController.provider)[roomId]; + final room = ref.read(SelectedRoomController.provider); if (room == null) return InMemoryChatController(); + + final messages = await ref.watch( + MessagesController.provider( + room.timeline + .map( + (timelineRowTuple) => room.events.firstWhereOrNull( + (event) => event.rowId == timelineRowTuple.eventRowId, + ), + ) + .nonNulls + .toIList(), + ).future, + ); + final controller = InMemoryChatController(messages: messages.toList()); + + ref.onDispose( + ref.listen(NewEventsController.provider(roomId), (_, next) async { + final controller = await future; + for (final event in next) { + if (event.type == "m.room.redaction") { + final controller = await future; + final message = controller.messages.firstWhereOrNull( + (message) => message.id == event.content["redacts"], + ); + if (message == null || !ref.mounted) return; + + await controller.removeMessage(message); + } else { + final message = await ref.watch( + MessageController.provider( + MessageConfig(event: event, 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 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 && + !controller.messages.any( + (oldMessage) => oldMessage.id == message.id, + ) && + ref.mounted) { + await controller.insertMessage(message); + } + } + } + }, weak: true).close, + ); + + ref.onDispose(controller.dispose); + + if (messages.length < 20) await loadOlder(controller); + final state = await client.getRoomState( - GetRoomStateRequest(roomId: roomId), + GetRoomStateRequest( + roomId: roomId, + fetchMembers: room.metadata?.hasMemberList == false, + includeMembers: true, + ), ); ref - .read(RoomsController.provider.notifier) + .watch(RoomsController.provider.notifier) .update( { roomId: Room( @@ -57,133 +129,6 @@ class RoomChatController extends AsyncNotifier { 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()); - - 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; - - return await controller.updateMessage( - message, - message.copyWith( - reactions: IMap(message.reactions) - .update( - key, - (reactors) => [...reactors, event.authorId], - ifAbsent: () => [event.authorId], - ) - .unlock, - ), - ); - } - - 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); - } - } - } - }, weak: true).close, - ); - - 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; } @@ -201,25 +146,30 @@ 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 + Future deleteMessage(Message message, {String? reason}) async { + final controller = await future; + await controller.removeMessage(message); + await ref .watch(ClientController.provider.notifier) - .paginate( - PaginateRequest( + .redactEvent( + RedactEventRequest( + eventId: message.id, roomId: roomId, - maxTimelineId: ref - .read(RoomsController.provider)[roomId] - ?.timeline - .firstOrNull - ?.timelineRowId, + reason: reason, ), ); + } + + Future loadOlder([InMemoryChatController? chatController]) async { + final controller = chatController ?? await future; + final client = ref.watch(ClientController.provider.notifier); + + final response = await client.paginate( + PaginateRequest( + roomId: roomId, + maxTimelineId: controller.messages.firstOrNull?.metadata?["timelineId"], + ), + ); ref .watch(RoomsController.provider.notifier) @@ -239,40 +189,33 @@ class RoomChatController extends AsyncNotifier { ), }), 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; + final messages = await ref.watch( + MessagesController.provider(response.events.reversed).future, + ); + await controller.insertAllMessages( + messages + .where( + (newMessage) => !controller.messages.any( + (message) => message.id == newMessage.id, + ), + ) + .toList(), + index: 0, + ); } + 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); @@ -285,15 +228,12 @@ class RoomChatController extends AsyncNotifier { } final client = ref.watch(ClientController.provider.notifier); - final room = ref.read(RoomsController.provider)[roomId]; - final event = await client.sendMessage( + client.sendMessage( SendMessageRequest( roomId: roomId, mentions: Mentions( userIds: [ - if (shouldMention == true && - relation != null && - relationType == RelationType.reply) + if (relation != null && relationType == RelationType.reply) relation.authorId, ].toIList(), room: taggedMessage.contains("@room"), @@ -304,87 +244,25 @@ class RoomChatController extends AsyncNotifier { : Relation(eventId: relation.id, relationType: relationType), ), ); - 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 ref + .watch(ClientController.provider.notifier) + .getProfile(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..0945644 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -1,9 +1,7 @@ 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"; @@ -11,18 +9,7 @@ class RoomsController extends Notifier> { @override IMap build() => const IMap.empty(); - void update( - IMap rooms, - ISet leftRooms, { - bool addToNewEvents = true, - }) { - final homeserver = - ref.watch( - ClientStateController.provider.select( - (value) => value?.homeserverUrl, - ), - ) ?? - ""; + void update(IMap rooms, ISet leftRooms) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; final incoming = entry.value; @@ -33,32 +20,23 @@ class RoomsController extends Notifier> { (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(), - ); - } + 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, + metadata: incoming.metadata ?? existing.metadata, events: events!, state: incoming.state.entries.fold( existing.state, @@ -88,11 +66,7 @@ class RoomsController extends Notifier> { ), ), ) ?? - incoming.copyWith( - metadata: incoming.metadata?.copyWith( - avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver), - ), - ), + incoming, ); }); diff --git a/lib/controllers/secure_storage_controller.dart b/lib/controllers/secure_storage_controller.dart new file mode 100644 index 0000000..4a5781b --- /dev/null +++ b/lib/controllers/secure_storage_controller.dart @@ -0,0 +1,19 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_secure_storage/flutter_secure_storage.dart"; + +class SecureStorageController extends Notifier { + @override + FlutterSecureStorage build() => FlutterSecureStorage(); + + Future get(String key) => state.read(key: key); + + Future set(String key, String value) => + state.write(key: key, value: value); + + Future clear() => state.deleteAll(); + + static final provider = + NotifierProvider( + SecureStorageController.new, + ); +} diff --git a/lib/controllers/space_edges_controller.dart b/lib/controllers/space_edges_controller.dart index 12694d6..0349f36 100644 --- a/lib/controllers/space_edges_controller.dart +++ b/lib/controllers/space_edges_controller.dart @@ -6,8 +6,7 @@ class SpaceEdgesController extends Notifier>> { @override IMap> build() => const IMap.empty(); - void set(IMap> newEdges) => - state = state.addAll(newEdges); + void set(IMap> newEdges) => state = newEdges; static final provider = NotifierProvider>>( diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index 7a503ad..292e323 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -1,8 +1,6 @@ -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"; @@ -42,11 +40,8 @@ class SpacesController extends Notifier> { final allNestedRoomIds = childRoomsBySpaceId.values .expand((l) => l) .map( - (room) => rooms.entries - .firstWhere( - (entry) => entry.value.metadata?.id == room.metadata?.id, - ) - .key, + (room) => + rooms.entries.firstWhere((entry) => entry.value == room).key, ) .toISet(); @@ -59,28 +54,12 @@ class SpacesController extends Notifier> { ) .map((e) => e.value); - 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, - ) + .where((room) => room.metadata?.dmUserId == null) .toIList(); final dmRooms = otherRooms - .where( - (room) => directMessages.any( - (directMessage) => directMessage == room.metadata?.id, - ), - ) + .where((room) => room.metadata?.dmUserId != null) .toIList(); final topLevelSpacesList = topLevelSpaceIds @@ -100,37 +79,15 @@ class SpacesController extends Notifier> { .toIList(); return [ - Space( - id: "home", - title: "Home", - icon: Icons.home, - children: homeRooms, - ), - 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(); + Space(id: "home", title: "Home", icon: Icons.home, children: homeRooms), + Space( + id: "dms", + title: "Direct Messages", + icon: Icons.people, + children: dmRooms, + ), + ...topLevelSpacesList, + ].toIList(); } static final provider = NotifierProvider>( diff --git a/lib/controllers/sync_status_controller.dart b/lib/controllers/sync_status_controller.dart index 8475d9d..fe65732 100644 --- a/lib/controllers/sync_status_controller.dart +++ b/lib/controllers/sync_status_controller.dart @@ -1,17 +1,11 @@ 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; - } + void set(SyncStatus newStatus) => state = newStatus; static final provider = NotifierProvider( SyncStatusController.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/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/join_room_with_snackbars.dart b/lib/helpers/extensions/join_room_with_snackbars.dart new file mode 100644 index 0000000..05b045d --- /dev/null +++ b/lib/helpers/extensions/join_room_with_snackbars.dart @@ -0,0 +1,90 @@ +import "package:collection/collection.dart"; +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/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"; + +extension JoinRoomWithSnackbars on ClientController { + Future joinRoomWithSnackBars( + BuildContext context, + String roomAlias, + WidgetRef ref, + ) async { + final roomIdOrAlias = roomAlias.mention ?? roomAlias; + + final scaffoldMessenger = ScaffoldMessenger.of(context); + + final snackbar = scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Joining room $roomIdOrAlias."), + duration: Duration(days: 999), + ), + ); + + try { + final id = await joinRoom( + JoinRoomRequest( + roomIdOrAlias: roomIdOrAlias, + via: IList(Uri.tryParse(roomAlias)?.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, + ), + ), + ), + ); + } + } + } +} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart index f4868d3..8095132 100644 --- a/lib/helpers/extensions/link_to_mention.dart +++ b/lib/helpers/extensions/link_to_mention.dart @@ -13,7 +13,7 @@ extension LinkToMention on String { final trimmed = trim(); final matrixTo = RegExp( - r"^https?://matrix\.to/#/(.[^/?#]+)", + r"^https?://matrix\.to/#/([^/?#]+)", caseSensitive: false, ); @@ -30,8 +30,7 @@ extension LinkToMention on String { final identifier = uri.pathSegments.last; if (identifier.isNotEmpty) { return "${switch (uri.pathSegments.firstOrNull) { - "r" => "#", - "roomid" => "!", + "r" || "roomid" => "#", "u" => "@", _ => "", }}${Uri.decodeComponent(identifier)}"; diff --git a/lib/helpers/extensions/mxc_to_https.dart b/lib/helpers/extensions/mxc_to_https.dart index 910f87d..468da12 100644 --- a/lib/helpers/extensions/mxc_to_https.dart +++ b/lib/helpers/extensions/mxc_to_https.dart @@ -1,5 +1,4 @@ extension MxcToHttps on Uri { - Uri mxcToHttps(String homeserver) => Uri.parse( - homeserver, - ).resolve("_matrix/client/v1/media/download/$host$path"); + Uri mxcToHttps(String homeserver) => + Uri.parse("${homeserver}_matrix/client/v1/media/download/$host$path"); } diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index df68a05..aff5d52 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -7,13 +7,8 @@ extension SchemeToTheme on ColorScheme { titleSpacing: 0, backgroundColor: surfaceContainerLow, ), - menuTheme: MenuThemeData( - style: MenuStyle( - backgroundColor: WidgetStatePropertyAll(primaryContainer), - ), - ), textTheme: ThemeData( - fontFamilyFallback: ["sans", "emoji"], + fontFamilyFallback: ["sans"], brightness: brightness, ).textTheme, inputDecorationTheme: const InputDecorationTheme( 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..ab65e4b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import "dart:developer"; import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; @@ -17,6 +18,7 @@ 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(); @@ -42,6 +44,7 @@ void showError(Object error, [StackTrace? stackTrace]) { if (error.toString().contains("Invalid image data")) return; debugPrintStack(stackTrace: stackTrace, label: error.toString()); + debugger(); if (navigatorKey.currentContext != null) { Future.delayed( Duration.zero, @@ -57,11 +60,14 @@ 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.ensureInitialized(); + await windowManager.waitUntilReadyToShow( + WindowOptions(titleBarStyle: TitleBarStyle.hidden), + ); + + if (Platform.isLinux) { + setWindowMinSize(const Size.square(500)); + } else { await windowManager.setMinimumSize(Size.square(500)); } @@ -116,7 +122,6 @@ class App extends StatelessWidget { final clientState = ref.watch( ClientStateController.provider, ); - if (clientState == null || !clientState.isInitialized) { return Loading(); } 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/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/emoji.dart b/lib/models/emoji.dart deleted file mode 100644 index 8e4eac6..0000000 --- a/lib/models/emoji.dart +++ /dev/null @@ -1,17 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -part "emoji.freezed.dart"; -part "emoji.g.dart"; - -@freezed -abstract class Emoji with _$Emoji { - const factory Emoji({ - required String emoji, - required String category, - required IList aliases, - required String description, - required IList tags, - }) = _Emoji; - - factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); -} diff --git a/lib/models/event.dart b/lib/models/event.dart index 734f667..623116b 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -27,7 +27,7 @@ abstract class Event with _$Event { String? decryptionError, String? sendError, @Default(IMap.empty()) IMap reactions, - @JsonKey(name: "last_edit_rowid") int? lastEditRowId, + int? lastEditRowId, @UnreadTypeConverter() UnreadType? unreadType, }) = _Event; @@ -38,7 +38,6 @@ abstract class Event with _$Event { abstract class LocalContent with _$LocalContent { const factory LocalContent({ String? sanitizedHtml, - String? editSource, bool? wasPlaintext, bool? bigEmoji, bool? hasMath, 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/configs/message_config.dart b/lib/models/message_config.dart similarity index 56% rename from lib/models/configs/message_config.dart rename to lib/models/message_config.dart index 66a437c..4e5ff71 100644 --- a/lib/models/configs/message_config.dart +++ b/lib/models/message_config.dart @@ -1,28 +1,16 @@ 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 mustBeText, @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/profile.dart b/lib/models/profile.dart index 584f27b..d92b4f6 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -3,22 +3,15 @@ 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, + @JsonKey(name: "us.cloke.msc4175.tz") String? timezone, @Default(IList.empty()) - @JsonKey(readValue: readPronouns) + @JsonKey(name: "io.fsky.nyx.pronouns") IList pronouns, }) = _Profile; diff --git a/lib/models/requests/get_event_request.dart b/lib/models/requests/get_event_request.dart index 9374f3a..3812d50 100644 --- a/lib/models/requests/get_event_request.dart +++ b/lib/models/requests/get_event_request.dart @@ -1,32 +1,15 @@ 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) +@freezed abstract class GetEventRequest with _$GetEventRequest { - const GetEventRequest._(); const factory GetEventRequest({ - required Room room, + required String roomId, 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_room_state_request.dart b/lib/models/requests/get_room_state_request.dart index 8ee05f0..a154d5f 100644 --- a/lib/models/requests/get_room_state_request.dart +++ b/lib/models/requests/get_room_state_request.dart @@ -6,8 +6,7 @@ part "get_room_state_request.g.dart"; abstract class GetRoomStateRequest with _$GetRoomStateRequest { const factory GetRoomStateRequest({ required String roomId, - @Default(false) bool refetch, - @Default(false) bool fetchMembers, + required bool fetchMembers, @Default(false) bool includeMembers, }) = _GetRoomStateRequest; 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/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/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/sync_data.dart b/lib/models/sync_data.dart index 0f98bb2..0fc18ac 100644 --- a/lib/models/sync_data.dart +++ b/lib/models/sync_data.dart @@ -1,6 +1,5 @@ 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"; @@ -10,7 +9,7 @@ part "sync_data.g.dart"; abstract class SyncData with _$SyncData { const factory SyncData({ @Default(false) bool clearState, - @Default(IMap.empty()) IMap accountData, + // required IMap accountData, @Default(IMap.empty()) IMap rooms, @Default(ISet.empty()) ISet leftRooms, // required IList invitedRooms, diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart index 7848fbe..42c5f2a 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -14,5 +14,5 @@ abstract class SyncStatus with _$SyncStatus { _$SyncStatusFromJson(json); } -@JsonEnum(fieldRename: FieldRename.kebab) +@JsonEnum(fieldRename: FieldRename.snake) enum SyncStatusType { ok, waiting, erroring, permanentlyFailed } diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 671891c..ee2f4d0 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,10 +1,7 @@ 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"; class ChatPage extends ConsumerWidget { const ChatPage({super.key}); @@ -14,33 +11,22 @@ class ChatPage extends ConsumerWidget { 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 d2153eb..b15f6d4 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -62,7 +62,7 @@ class LoginPage extends HookConsumerWidget { children: [ Row( children: [ - SvgPicture.asset("assets/icon.svg", width: 128), + SvgPicture.asset("assets/icon.svg"), SizedBox(width: 12), Expanded( child: Column( @@ -97,7 +97,6 @@ class LoginPage extends HookConsumerWidget { ), ), IconButton.filled( - tooltip: "Confirm homeserver choice", onPressed: isLoading.value ? null : () => setHomeserver(Uri.tryParse(homeserverUrl.text)), @@ -144,7 +143,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), ), @@ -175,7 +173,7 @@ class LoginPage extends HookConsumerWidget { ElevatedButton( onPressed: () async { isLoading.value = true; - final error = await client.login( + final succeeded = await client.login( LoginRequest( username: username.text, password: password.text, @@ -183,11 +181,11 @@ class LoginPage extends HookConsumerWidget { ), ); - 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 index 962701c..1011f80 100644 --- a/lib/pages/verify_page.dart +++ b/lib/pages/verify_page.dart @@ -2,7 +2,6 @@ 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 { @@ -12,75 +11,72 @@ class VerifyPage extends HookConsumerWidget { 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"), + return 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 success = await ref + .watch(ClientController.provider.notifier) + .verify(passphraseController.text); + + snackbar.close(); + if (!success) { + isVerifying.value = false; + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + backgroundColor: Theme.of( + context, + ).colorScheme.errorContainer, + content: Text( + "Verification failed. Is your passphrase correct?", + 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..00b0e4c 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -35,28 +35,24 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget { } return GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: maximize, 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), + title: title, 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), - ), + IconButton(onPressed: () => exit(0), icon: const Icon(Icons.close)), ], ], ), diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 28662e2..a47bbb5 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -2,8 +2,10 @@ 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/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; class AvatarOrHash extends ConsumerWidget { final Uri? avatar; @@ -38,7 +40,7 @@ class AvatarOrHash extends ConsumerWidget { smallSize: 12, backgroundColor: Theme.of(context).colorScheme.primary, child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular((height - 8) / 2.5)), + borderRadius: BorderRadius.all(Radius.circular(4)), child: SizedBox( width: height, height: height, @@ -46,11 +48,20 @@ class AvatarOrHash extends ConsumerWidget { ? fallback ?? box : Image( image: CachedNetworkImage( - avatar.toString(), + avatar! + .mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ) + .toString(), ref.watch(CrossCacheController.provider), headers: ref.headers, ), - fit: BoxFit.cover, + fit: BoxFit.contain, errorBuilder: (_, _, _) => box, ), ), diff --git a/lib/widgets/chat_page/chat_box.dart b/lib/widgets/chat_page/chat_box.dart new file mode 100644 index 0000000..885ddd9 --- /dev/null +++ b/lib/widgets/chat_page/chat_box.dart @@ -0,0 +1,158 @@ +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/room_chat_controller.dart"; +import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/room.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 Room room; + const ChatBox({ + required this.relatedMessage, + required this.relationType, + required this.onDismiss, + required this.room, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final controller = useRef(FlutterTaggerController()); + final triggerCharacter = useState(""); + final query = useState(""); + + if (relationType == RelationType.edit && + relatedMessage is TextMessage && + controller.value.text.isEmpty) { + final text = (relatedMessage as TextMessage).text; + final splitText = relatedMessage?.replyToMessageId == null + ? text + : text.split("\n\n").sublist(1).join("\n\n"); + final notEmpty = splitText.isEmpty ? text : splitText; + controller.value.text = notEmpty.startsWith("* ") + ? notEmpty.substring(2) + : notEmpty; + } + + void send() { + if (controller.value.text.trim().isEmpty || room.metadata == null) return; + ref + .watch(RoomChatController.provider(room.metadata!.id).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, + ); + + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Padding( + padding: EdgeInsetsGeometry.all(12), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: Column( + children: [ + RelationPreview( + relatedMessage: relatedMessage, + relationType: relationType, + onDismiss: onDismiss, + ), + Container( + color: theme.colorScheme.surfaceContainerHighest, + padding: EdgeInsets.symmetric(horizontal: 8), + child: Row( + spacing: 8, + children: [ + PopupMenuButton( + itemBuilder: (context) => [], + icon: Icon(Icons.add), + // enabled: room.canSendDefaultMessages, TODO: Permissions check + ), + 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(); + }, + ), + controller: controller.value, + onSearch: (newQuery, newTriggerCharacter) { + triggerCharacter.value = newTriggerCharacter; + query.value = newQuery; + }, + triggerCharacterAndStyles: {"@": style, "#": style}, + builder: (context, key) => TextFormField( + // enabled: room.canSendDefaultMessages, + maxLines: 12, + minLines: 1, + decoration: InputDecoration( + // 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, + autofocus: true, + focusNode: node, + ), + ), + ), + IconButton( + onPressed: send, + // onPressed: room.canSendDefaultMessages ? send : null, + icon: Icon(Icons.send), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/chat_page/composer/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart deleted file mode 100644 index dee52e1..0000000 --- a/lib/widgets/chat_page/composer/chat_box.dart +++ /dev/null @@ -1,188 +0,0 @@ -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: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: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"; - -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; - const ChatBox({ - required this.relatedMessage, - required this.relationType, - required this.onDismiss, - required this.onSend, - this.node, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - 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"] ?? ""; - } - - void send() { - if (controller.value.text.isEmpty) return; - onSend( - controller.value.formattedText, - shouldMention: shouldMention.value, - tags: controller.value.tags.toIList(), - ); - - onDismiss(); - controller.value.text = ""; - } - - 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, - right: 0, - child: Padding( - padding: EdgeInsetsGeometry.all(12), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Column( - children: [ - RelationPreview( - relatedMessage, - shouldMention: shouldMention.value, - toggleShouldMention: () => - shouldMention.value = !shouldMention.value, - relationType: relationType, - onDismiss: onDismiss, - ), - Container( - color: theme.colorScheme.surfaceContainerHighest, - padding: EdgeInsets.symmetric(horizontal: 8), - child: Row( - spacing: 8, - mainAxisAlignment: MainAxisAlignment.center, - children: canSendMessages - ? [ - 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), - ), - ), - ], - icon: Icon(Icons.add), - ), - Expanded( - child: FlutterTagger( - triggerStrategy: TriggerStrategy.eager, - overlay: MentionOverlay( - query: query.value, - triggerCharacter: triggerCharacter.value, - addTag: ({required id, required name}) { - controller.value.addTag(id: id, name: name); - node?.requestFocus(); - }, - ), - controller: controller.value, - onSearch: (newQuery, newTriggerCharacter) { - triggerCharacter.value = newTriggerCharacter; - query.value = newQuery; - }, - triggerCharacterAndStyles: { - "@": style, - "#": style, - }, - builder: (context, key) => TextFormField( - enabled: canSendMessages, - maxLines: 12, - minLines: 1, - autofocus: true, - decoration: InputDecoration( - hintText: "Your message here...", - border: InputBorder.none, - ), - controller: controller.value, - key: key, - onFieldSubmitted: (_) => send(), - // Don't defocus on submit - onEditingComplete: () {}, - textInputAction: TextInputAction.done, - focusNode: node, - ), - ), - ), - IconButton( - onPressed: !canSendMessages ? null : send, - icon: Icon(Icons.send), - tooltip: "Send message", - ), - ] - : [ - Padding( - padding: EdgeInsetsGeometry.all(8), - child: Text( - "You don't have permission to send messages in this room...", - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} 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 e8805ca..0000000 --- a/lib/widgets/chat_page/emoji_picker_button.dart +++ /dev/null @@ -1,51 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/emoji_controller.dart"; - -class EmojiPickerButton extends HookConsumerWidget { - 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(_, WidgetRef ref) => IconButton( - onPressed: () async { - onPressed?.call(); - final controller = this.controller ?? TextEditingController(); - - final emojis = await ref.watch(EmojiController.provider.future); - if (context.mounted) { - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: EmojiViewConfig( - showRecentTab: false, - customCategories: emojis.$1.unlock, - customKeywords: emojis.$2.unlock, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() { - // Without this, there will sometimes be a debugLocked is not true error sometimes - Future.delayed(Duration.zero, () { - if (context.mounted) 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..18edf4a 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -1,15 +1,12 @@ -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: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/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"; @@ -17,37 +14,18 @@ import "package:nexus/widgets/chat_page/html/quoted.dart"; class Html extends ConsumerWidget { final String html; - final TextStyle? textStyle; - const Html(this.html, {this.textStyle, super.key}); + const Html(this.html, {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" => @@ -63,40 +41,38 @@ class Html extends ConsumerWidget { "a" => element.attributes["href"]?.mention == null ? null - : InlineCustomWidget( - child: MentionChip(element.attributes["href"]!), - ), + : InlineCustomWidget(child: MentionChip(element.text)), "img" => - src == null - ? SizedBox.shrink() + element.attributes["src"] == null + ? null : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, - child: ExpandableImage( - src, - child: Image( - image: CachedNetworkImage( - src, - ref.watch(CrossCacheController.provider), - headers: ref.headers, + child: Image.network( + Uri.parse(element.attributes["src"]!) + .mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value?.homeserverUrl, + ), + ) ?? + "", + ) + .toString(), + headers: ref.headers, + errorBuilder: (_, error, _) => Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), - 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(), ), + height: height.toDouble(), + width: width?.toDouble(), + loadingBuilder: (_, child, loadingProgress) => + loadingProgress == null + ? child + : CircularProgressIndicator(), ), ), - - // Allowed elements list ("del" || "h1" || "h2" || @@ -143,7 +119,9 @@ class Html extends ConsumerWidget { .mapTo?>( (key, value) => switch (key) { "data-mx-color" => MapEntry("color", value), + "data-mx-bg-color" => MapEntry("background-color", value), + _ => null, }, ) diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart index 575ad03..c2b832d 100644 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -1,44 +1,25 @@ 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"; -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.mention ?? label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, ), - ); - } + ), + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Text("TODO: Open room or join room dialog, or user popover"), + ), + ), + ); } 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..5e1f3bf 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -1,94 +1,58 @@ 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: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/models/room.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, + AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text("Members (${members.length})"), + actionsPadding: EdgeInsets.only(right: 4), + actions: [ + if (Scaffold.of(context).hasEndDrawer) + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + ), + ], ), - 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, + ...members.map( + (member) => ListTile( + onTap: () => showDialog( + context: context, + builder: (context) => + Dialog(child: Text("TODO: Open member popover")), + ), + leading: AvatarOrHash( + Uri.tryParse(member.content["avatar_url"] ?? ""), + member.content["displayname"].toString(), + ), + title: Text( + member.content["displayname"].toString(), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + member.authorId, + 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/composer/mention_overlay.dart b/lib/widgets/chat_page/mention_overlay.dart similarity index 54% rename from lib/widgets/chat_page/composer/mention_overlay.dart rename to lib/widgets/chat_page/mention_overlay.dart index b650421..b2f2d9d 100644 --- a/lib/widgets/chat_page/composer/mention_overlay.dart +++ b/lib/widgets/chat_page/mention_overlay.dart @@ -1,18 +1,19 @@ 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/members_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/models/room.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({ + const MentionOverlay( + this.room, { required this.query, required this.addTag, required this.triggerCharacter, @@ -33,9 +34,7 @@ class MentionOverlay extends ConsumerWidget { child: switch (triggerCharacter) { "@" => ref - .watch( - MembersByTypeController.provider(MembershipStatus.join), - ) + .watch(MembersController.provider(room)) .betterWhen( data: (members) => ListView( children: @@ -43,12 +42,12 @@ class MentionOverlay extends ConsumerWidget { ? members : members.where( (member) => - member.userId.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - member.displayName - .toLowerCase() + member.authorId + .toLowerCase() + .contains(query.toLowerCase()) || + (member.content["displayname"] + as String?) + ?.toLowerCase() .contains( query.toLowerCase(), ) == @@ -57,14 +56,18 @@ class MentionOverlay extends ConsumerWidget { .map( (member) => ListTile( leading: AvatarOrHash( - member.avatarUrl, - member.displayName, + Uri.tryParse( + member.content["avatar_url"] ?? "", + ), + member.content["displayname"] ?? "", + ), + title: Text( + member.content["displayname"] as String? ?? + member.authorId, ), - title: Text(member.displayName), - subtitle: Text(member.userId), onTap: () => addTag( - id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})", - name: member.userId + id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.authorId})", + name: member.authorId .substring(1) .split(":") .first, @@ -79,43 +82,33 @@ class MentionOverlay extends ConsumerWidget { (query.isEmpty ? rooms.values : rooms.values.where( - (room) => - (room.metadata?.name ?? room.metadata!.id) - .toLowerCase() - .contains(query.toLowerCase()), + (room) => (room.metadata?.name ?? "Unnamed Room") + .toLowerCase() + .contains(query.toLowerCase()), )) - .map((room) { - final name = - room.metadata?.name ?? - room.metadata!.canonicalAlias ?? - room.metadata!.id; - return ListTile( + .map( + (room) => ListTile( leading: AvatarOrHash( room.metadata?.avatar, - name, + room.metadata?.name ?? "Unnamed Room", fallback: Icon(Icons.numbers), ), - title: Text(name), + title: Text(room.metadata?.name ?? "Unnamed Room"), 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 ?? - "", - ); - }, - ); - }) + onTap: () => addTag( + id: "[#${room.metadata?.name ?? "Unnamed Room"}](https://matrix.to/#/${room.metadata?.id})", + name: + (room.metadata?.canonicalAlias ?? + room.metadata?.id) + ?.substring(1) + .split(":") + .first ?? + "", + ), + ), + ) .toList(), ), diff --git a/lib/widgets/chat_page/relation_preview.dart b/lib/widgets/chat_page/relation_preview.dart new file mode 100644 index 0000000..9918b35 --- /dev/null +++ b/lib/widgets/chat_page/relation_preview.dart @@ -0,0 +1,73 @@ +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"; + +class RelationPreview extends ConsumerWidget { + final Message? relatedMessage; + final RelationType relationType; + final VoidCallback onDismiss; + const RelationPreview({ + required this.relatedMessage, + required this.relationType, + required this.onDismiss, + 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..21aa4ae 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/models/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 Room 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,42 @@ 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.metadata?.avatar, + room.metadata?.name ?? "Unnamed Rooms", + height: 24, + fallback: Icon(Icons.numbers), + ) + : DrawerButton(onPressed: () => onOpenDrawer(context)), + scrolledUnderElevation: 0, + title: 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, ), - 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), + ].toIList(), + ); } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 7fb3f8f..6adc013 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -1,34 +1,32 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:cross_cache/cross_cache.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/cross_cache_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"; +// import "package:dynamic_polls/dynamic_polls.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; @@ -42,138 +40,46 @@ 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 room = ref.watch(SelectedRoomController.provider); 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, - ), + if (room == null || userId == null || room.metadata?.id == null) { + return Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, ), ); } - final controllerProvider = RoomChatController.provider(roomId); + final controllerProvider = RoomChatController.provider(room.metadata!.id); 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 [ - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.reaction"), - ), - )) - 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); - }, - icon: Text(emoji), - ), - ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, message).onError(showError), - ), - ], - ), - ), - if (ref.watch( - PowerLevelController.provider( - PowerLevelConfig(eventType: "m.room.message"), - ), - )) - PopupMenuItem( - onTap: () { - relatedMessage.value = message; - relationType.value = RelationType.reply; - composerNode.requestFocus(); - }, - child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), - ), + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.reply; + }, + child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), if (message is TextMessage && isSentByMe) PopupMenuItem( onTap: () { - relatedMessage.value = message; + replyToMessage.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"), - ), - )) + if (isSentByMe) // TODO: Or if user has permission to redact others' messages PopupMenuItem( onTap: () => showDialog( context: context, @@ -205,13 +111,11 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () async { + notifier.deleteMessage( + message, + reason: deleteReasonController.text, + ); Navigator.of(context).pop(); - await notifier - .deleteMessage( - message, - reason: deleteReasonController.text, - ) - .onError(showError); }, child: Text("Delete"), ), @@ -220,10 +124,7 @@ class RoomChat extends HookConsumerWidget { }, ), ), - child: ListTile( - leading: Icon(Icons.delete, color: danger), - title: Text("Delete", style: TextStyle(color: danger)), - ), + child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), ), PopupMenuItem( onTap: () => showDialog( @@ -257,9 +158,10 @@ class RoomChat extends HookConsumerWidget { ), TextButton( onPressed: () { + if (room.metadata == null) return; client.reportEvent( ReportRequest( - roomId: roomId, + roomId: room.metadata!.id, eventId: message.id, reason: reasonController.text.isEmpty ? null @@ -283,15 +185,9 @@ class RoomChat extends HookConsumerWidget { ]; } - final chatTheme = ChatTheme.fromThemeData(theme).copyWith( - colors: ChatColors.fromThemeData(theme).copyWith( - primary: theme.colorScheme.primaryContainer, - onPrimary: theme.colorScheme.onPrimaryContainer, - ), - ); - return Scaffold( appBar: RoomAppbar( + room, isDesktop: isDesktop, onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: (thisContext) { @@ -310,7 +206,12 @@ class RoomChat extends HookConsumerWidget { .betterWhen( data: (controller) => Chat( currentUserId: userId, - theme: chatTheme, + theme: ChatTheme.fromThemeData(theme).copyWith( + colors: ChatColors.fromThemeData(theme).copyWith( + primary: theme.colorScheme.primaryContainer, + onPrimary: theme.colorScheme.onPrimaryContainer, + ), + ), onMessageSecondaryTap: ( context, @@ -333,50 +234,150 @@ class RoomChat extends HookConsumerWidget { globalPosition: details.globalPosition, children: getMessageOptions(message), ), + onMessageTap: + ( + context, + message, { + required details, + required index, + }) { + if (message is ImageMessage) { + showDialog( + context: context, + builder: (_) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.all(64), + child: InteractiveViewer( + child: Image( + image: CachedNetworkImage( + message.source, + ref.watch( + CrossCacheController.provider, + ), + headers: ref.headers, + ), + ), + ), + ), + ); + } + }, builders: Builders( - loadMoreBuilder: (_) => SizedBox.shrink(), - + loadMoreBuilder: (_) => Loading(), chatAnimatedListBuilder: (_, itemBuilder) => ChatAnimatedList( itemBuilder: itemBuilder, - onEndReached: - ref.watch( - SelectedRoomController.provider.select( - (room) => room?.hasMore == true, - ), - ) + onEndReached: room.hasMore ? notifier.loadOlder : null, - onStartReached: () async { - final room = ref.watch( - SelectedRoomController.provider, - ); - return room == null - ? null - : await client.markRead(room); - }, + onStartReached: () => 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, + relatedMessage: replyToMessage.value, + onDismiss: () => replyToMessage.value = null, + room: room, ), + // TODO: Polls + // customMessageBuilder: + // ( + // context, + // message, + // index, { + // required bool isSentByMe, + // MessageGroupStatus? groupStatus, + // }) { + // final poll = + // message.metadata?["poll"] + // as PollStartContent; + // final responses = + // (message.metadata?["responses"] + // as Map< + // String, + // Set + // >) + // .values + // .expand((set) => set) + // .fold({}, ( + // acc, + // value, + // ) { + // acc[value] = + // (acc[value] ?? 0) + 1; + // return acc; + // }); + + // return Column( + // crossAxisAlignment: + // CrossAxisAlignment.start, + // spacing: 4, + // children: [ + // TopWidget( + // message, + // headers: room + // .roomData + // .client + // .headers, + // groupStatus: groupStatus, + // ), + + // DynamicPolls( + // startDate: DateTime.now(), + // endDate: DateTime.now(), + // private: + // poll.kind == + // PollKind.undisclosed, + // allowReselection: true, + // backgroundDecoration: + // BoxDecoration( + // borderRadius: + // BorderRadius.all( + // Radius.circular(16), + // ), + // border: Border.all( + // color: theme + // .colorScheme + // .primaryContainer, + // width: 4, + // ), + // ), + // allStyle: Styles( + // titleStyle: TitleStyle( + // style: theme + // .textTheme + // .headlineSmall, + // ), + // optionStyle: OptionStyle( + // fillColor: theme + // .colorScheme + // .primaryContainer, + // selectedBorderColor: theme + // .colorScheme + // .primary, + // borderColor: theme + // .colorScheme + // .primary, + // unselectedBorderColor: + // Colors.transparent, + // textSelectColor: theme + // .colorScheme + // .primary, + // ), + // ), + // onOptionSelected: + // (int index) {}, + // title: poll.question.mText, + // options: poll.answers + // .map( + // (option) => option.mText, + // ) + // .toList(), + // ), + // ], + // ); + // }, textMessageBuilder: ( context, @@ -384,35 +385,98 @@ class RoomChat extends HookConsumerWidget { index, { required bool isSentByMe, MessageGroupStatus? groupStatus, - }) => TextMessageWrapper( - message, - content: message.text, - groupStatus: groupStatus, - onTapReply: notifier.scrollToMessage, - updateMessage: controller.updateMessage, - isSentByMe: isSentByMe, - ), + }) => FlyerChatTextMessage( + customWidget: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Html( + (message.metadata?["formatted"] + as String) + .replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + ), + (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"; + }, + ) + .replaceAll("\n", "
"), + ), + if (message.editedAt != null) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + ], + ), + topWidget: TopWidget( + message, + 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: ( - 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( + }) => FlyerChatImageMessage( + topWidget: TopWidget( message, - index: index, + groupStatus: groupStatus, + alwaysShow: true, ), + 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, ), - fileMessageBuilder: ( _, @@ -420,30 +484,22 @@ class RoomChat extends HookConsumerWidget { 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, + }) => InkWell( + onTap: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Text("TODO: Download Attachments"), ), ), - groupStatus, + child: FlyerChatFileMessage( + topWidget: TopWidget( + message, + groupStatus: groupStatus, + ), + message: message, + index: index, + ), ), - systemMessageBuilder: ( _, @@ -455,7 +511,6 @@ class RoomChat extends HookConsumerWidget { message: message, index: index, ), - unsupportedMessageBuilder: ( _, @@ -470,7 +525,7 @@ class RoomChat extends HookConsumerWidget { ), ), ), - resolveUser: (_) async => null, + resolveUser: notifier.resolveUser, chatController: controller, ), ), @@ -480,11 +535,11 @@ class RoomChat extends HookConsumerWidget { ), if (memberListOpened.value == true && showMembersByDefault) - MemberList(), + MemberList(room), ], ), - endDrawer: showMembersByDefault ? null : MemberList(), + endDrawer: showMembersByDefault ? null : MemberList(room), ); } } diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart index 4405707..2687bc8 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -1,9 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.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"; class RoomMenu extends ConsumerWidget { @@ -18,6 +16,13 @@ class RoomMenu extends ConsumerWidget { return PopupMenuButton( itemBuilder: (_) => [ + // PopupMenuItem( + // onTap: () async { + // final link = await room.matrixToInviteLink(); + // await Clipboard.setData(ClipboardData(text: link.toString())); + // }, + // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), + // ), PopupMenuItem( onTap: () async { await client.markRead(room); @@ -28,18 +33,6 @@ class RoomMenu extends ConsumerWidget { 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)", - ), - ); - }, - child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), - ), PopupMenuItem( onTap: () => showDialog( context: context, diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart index f79c38f..341dd60 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/chat_page/sidebar.dart @@ -1,15 +1,18 @@ 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/selected_space_controller.dart"; import "package:nexus/controllers/spaces_controller.dart"; +import "package:nexus/helpers/extensions/join_room_with_snackbars.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"; +import "package:nexus/widgets/form_text_input.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) { @@ -88,7 +91,53 @@ class Sidebar extends HookConsumerWidget { PopupMenuItem( onTap: () => showDialog( context: context, - builder: (_) => JoinDialog(ref), + builder: (alertContext) => HookBuilder( + builder: (_) { + final roomAlias = useTextEditingController(); + return AlertDialog( + title: Text("Join a Room"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter the room alias, ID, or a 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(alertContext).pop(); + + final client = ref.watch( + ClientController.provider.notifier, + ); + if (context.mounted) { + client.joinRoomWithSnackBars( + context, + roomAlias.text, + ref, + ); + } + }, + child: Text("Join"), + ), + ], + ); + }, + ), ), child: ListTile( title: Text("Join an existing room (or space)"), @@ -96,7 +145,7 @@ class Sidebar extends HookConsumerWidget { ), ), PopupMenuItem( - onTap: null, + onTap: () {}, child: ListTile( title: Text("Create a new room"), leading: Icon(Icons.add), @@ -106,16 +155,16 @@ class Sidebar extends HookConsumerWidget { icon: Icon(Icons.add), ), IconButton( - tooltip: "Explore other rooms", - onPressed: null, + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog(title: Text("To-do")), + ), icon: Icon(Icons.explore), ), IconButton( - tooltip: "Open settings", - onPressed: null, - // () => Navigator.of( - // context, - // ).push(MaterialPageRoute(builder: (_) => SettingsPage())), + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => SettingsPage())), icon: Icon(Icons.settings), ), ], @@ -169,12 +218,9 @@ class Sidebar extends HookConsumerWidget { ), ) .toList(), - onDestinationSelected: (value) { - selectedRoomIdNotifier.set( - selectedSpace.children[value].metadata?.id, - ); - if (!isDesktop) Navigator.of(context).pop(); - }, + onDestinationSelected: (value) => selectedRoomIdNotifier.set( + selectedSpace.children[value].metadata?.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..cfba6fc --- /dev/null +++ b/lib/widgets/chat_page/top_widget.dart @@ -0,0 +1,124 @@ +import "dart:math"; +import "package:flutter/material.dart"; +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/chat_page/html/quoted.dart"; + +class TopWidget extends ConsumerWidget { + final Message message; + final bool alwaysShow; + final MessageGroupStatus? groupStatus; + const TopWidget( + this.message, { + 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( + onTap: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Text("TODO: Scroll to original message"), + ), // TODO + ), + child: Quoted( + Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + AvatarOrHash( + Uri.tryParse(replyMessage.metadata?["avatarUrl"] ?? ""), + replyMessage.metadata?["displayName"] ?? "", + height: 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: () => showDialog( + context: context, + builder: (_) => + Dialog(child: Text("TODO: Show user profile")), // TODO + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + AvatarOrHash( + Uri.parse(message.metadata?["avatarUrl"] ?? ""), + message.metadata?["displayName"] ?? "", + ), + 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 5e8fe86..0000000 --- a/lib/widgets/chat_page/wrappers/reaction_row.dart +++ /dev/null @@ -1,116 +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 Tooltip( - message: reactors.join(", "), - child: ChoiceChip( - showCheckmark: false, - selected: selected, - label: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - Flexible( - child: reaction.startsWith("mxc://") - ? Image( - height: 20, - image: CachedNetworkImage( - headers: ref.headers, - Uri.parse(reaction) - .mxcToHttps( - clientState.homeserverUrl!, - ) - .toString(), - ref.watch( - CrossCacheController.provider, - ), - ), - ) - : Text( - reaction, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - reactors.length.toString(), - overflow: TextOverflow.ellipsis, - ), - ], - ), - 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/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..dffacff 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,9 +8,11 @@ #include #include +#include #include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = @@ -19,6 +21,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); 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); @@ -28,4 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { 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..8b658f4 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,9 +5,11 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_linux + flutter_secure_storage_linux screen_retriever_linux url_launcher_linux window_manager + window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST 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 adaeb15..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-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; - }; - - 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 984341b..222e779 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: @@ -348,21 +348,11 @@ packages: dynamic_system_colors: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "3b61760d5e0ac1229eefde5b61247947eede4110" - url: "https://github.com/hasali19/flutter_dynamic_system_colors" - source: git + name: dynamic_system_colors + sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c" + url: "https://pub.dev" + source: hosted version: "1.8.0" - emoji_text_field: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "5f7baaf8a6f059ec3ab8ff0f5d02339b00bf6997" - url: "https://github.com/Henry-Hiles/emoji_text_field" - source: git - version: "1.0.0" encrypt: dependency: transitive description: @@ -475,10 +465,11 @@ packages: flutter_chat_ui: dependency: "direct main" description: - name: flutter_chat_ui - sha256: cfbaac38f429beb33d9cc1ca920ae7ccbadbed282c99335d590d61306d3a3d0f - url: "https://pub.dev" - source: hosted + path: "packages/flutter_chat_ui" + ref: HEAD + resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git version: "2.11.1" flutter_hooks: dependency: "direct main" @@ -499,19 +490,12 @@ packages: flutter_link_previewer: dependency: "direct main" description: - name: flutter_link_previewer - sha256: "346f345064e65bc8bf739bccf19d6d6ca50f8183ffc52e452afa58c06ee2cbf7" - url: "https://pub.dev" - source: hosted + path: "packages/flutter_link_previewer" + ref: HEAD + resolved-ref: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git 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" flutter_lints: dependency: "direct dev" description: @@ -525,6 +509,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 +529,58 @@ 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_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_svg: dependency: "direct main" description: @@ -599,6 +639,15 @@ 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: "03be67c8c81c8f637672ee03dd8f082d2c223627" + url: "https://github.com/Henry-Hiles/flutter_chat_ui" + source: git + version: "2.6.0" freezed: dependency: "direct dev" description: @@ -639,6 +688,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: @@ -659,10 +716,10 @@ packages: 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: @@ -672,7 +729,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -839,14 +896,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: @@ -879,6 +928,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + 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: @@ -1075,26 +1132,26 @@ packages: 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: @@ -1380,14 +1437,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: @@ -1580,6 +1637,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: diff --git a/pubspec.yaml b/pubspec.yaml index dbed5c5..7893653 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "Yet another Matrix client" -version: 0.1.0 +version: 1.0.0 publish_to: none flutter: @@ -21,8 +21,8 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter - flutter_riverpod: ^3.3.1 - hooks_riverpod: ^3.3.1 + 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,23 +31,37 @@ 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 color_hash: ^1.0.1 flutter_widget_from_html_core: ^0.17.0 flutter_svg: ^2.2.2 json_annotation: ^4.9.0 shared_preferences: ^2.5.3 + mention_tag_text_field: ^0.0.9 fluttertagger: ^2.3.1 + flutter_secure_storage: ^10.0.0 dynamic_polls: ^0.0.6 flutter_hooks: ^0.21.3+1 cross_cache: ^1.1.0 @@ -55,19 +69,13 @@ dependencies: 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,9 +83,8 @@ flutter_launcher_icons: ios: true android: true image_path: assets/icon.png - adaptive_icon_background: assets/background.png + adaptive_icon_background: "#000000" adaptive_icon_foreground: assets/foreground.png - adaptive_icon_monochrome: assets/monochrome.png remove_alpha_ios: true windows: generate: true \ No newline at end of file diff --git a/scripts/generate.dart b/scripts/generate.dart index 446a469..9806603 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -3,11 +3,28 @@ import "package:ffigen/ffigen.dart"; import "package:path/path.dart"; void main(List args) async { - final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks")); + final repoDir = Directory.fromUri( + Platform.script.resolve("../src/gomuks/source"), + ); + if (await repoDir.exists()) await repoDir.delete(recursive: true); + await repoDir.create(recursive: true); + + print("Cloning Gomuks repository..."); + final cloneResult = await Process.run("git", [ + "clone", + "--depth", + "1", + "https://mau.dev/gomuks/gomuks", + repoDir.path, + ]); + + if (cloneResult.exitCode != 0) { + throw Exception( + "Failed to clone Gomuks repository: \n${cloneResult.stderr}", + ); + } print("Generating FFI Bindings..."); - - final libclangPath = Platform.environment["LIBCLANG_PATH"]; FfiGenerator( output: Output( dartFile: Platform.script.resolve("../lib/src/third_party/gomuks.g.dart"), @@ -17,10 +34,6 @@ void main(List args) async { compilerOptions: ["--no-warnings"], ), functions: Functions.includeAll, - ).generate( - libclangDylib: libclangPath == null - ? null - : Uri.file(join(libclangPath, "libclang.so")), - ); + ).generate(); print("Done!"); } diff --git a/scripts/generate.sh b/scripts/generate.sh new file mode 100755 index 0000000..faafd29 --- /dev/null +++ b/scripts/generate.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +pushd "$(dirname "$(readlink -f "$0")")"/.. || exit + +mkdir -p build +touch build/lock +dart scripts/generate.dart +rm build/lock + +popd || exit \ No newline at end of file diff --git a/src/gomuks/libgomuks.h b/src/gomuks/libgomuks.h new file mode 100644 index 0000000..962d281 --- /dev/null +++ b/src/gomuks/libgomuks.h @@ -0,0 +1,105 @@ +/* Code generated by cmd/cgo; DO NOT EDIT. */ + +/* package go.mau.fi/gomuks/pkg/ffi */ + + +#line 1 "cgo-builtin-export-prolog" + +#include + +#ifndef GO_CGO_EXPORT_PROLOGUE_H +#define GO_CGO_EXPORT_PROLOGUE_H + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef struct { const char *p; ptrdiff_t n; } _GoString_; +extern size_t _GoStringLen(_GoString_ s); +extern const char *_GoStringPtr(_GoString_ s); +#endif + +#endif + +/* Start of preamble from import "C" comments. */ + + +#line 9 "ffi.go" + +#include "gomuksffi.h" +#include + +static inline void _gomuks_callEventCallback(EventCallback cb, const char *command, int64_t request_id, GomuksOwnedBuffer data) { + cb(command, request_id, data); +} + +#line 1 "cgo-generated-wrapper" + + +/* End of preamble from import "C" comments. */ + + +/* Start of boilerplate cgo prologue. */ +#line 1 "cgo-gcc-export-header-prolog" + +#ifndef GO_CGO_PROLOGUE_H +#define GO_CGO_PROLOGUE_H + +typedef signed char GoInt8; +typedef unsigned char GoUint8; +typedef short GoInt16; +typedef unsigned short GoUint16; +typedef int GoInt32; +typedef unsigned int GoUint32; +typedef long long GoInt64; +typedef unsigned long long GoUint64; +typedef GoInt64 GoInt; +typedef GoUint64 GoUint; +typedef size_t GoUintptr; +typedef float GoFloat32; +typedef double GoFloat64; +#ifdef _MSC_VER +#if !defined(__cplusplus) || _MSVC_LANG <= 201402L +#include +typedef _Fcomplex GoComplex64; +typedef _Dcomplex GoComplex128; +#else +#include +typedef std::complex GoComplex64; +typedef std::complex GoComplex128; +#endif +#else +typedef float _Complex GoComplex64; +typedef double _Complex GoComplex128; +#endif + +/* + static assertion to make sure the file is being used on architecture + at least with matching size of GoInt. +*/ +typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef _GoString_ GoString; +#endif +typedef void *GoMap; +typedef void *GoChan; +typedef struct { void *t; void *v; } GoInterface; +typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; + +#endif + +/* End of boilerplate cgo prologue. */ + +#ifdef __cplusplus +extern "C" { +#endif + +extern GomuksHandle GomuksInit(void); +extern int GomuksStart(GomuksHandle handle, EventCallback callback); +extern void GomuksDestroy(GomuksHandle handle); +extern GomuksResponse GomuksSubmitCommand(GomuksHandle handle, char* command, GomuksBorrowedBuffer data); +extern GomuksAccountInfo GomuksGetAccountInfo(GomuksHandle handle); +extern void GomuksFreeAccountInfo(GomuksAccountInfo info); +extern void GomuksFreeBuffer(GomuksOwnedBuffer buf); + +#ifdef __cplusplus +} +#endif diff --git a/src/gomuks/libgomuks.so b/src/gomuks/libgomuks.so new file mode 100644 index 0000000..c4987c5 Binary files /dev/null and b/src/gomuks/libgomuks.so differ diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bde1c28..b12edca 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,19 +8,25 @@ #include #include +#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); + WindowSizePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowSizePlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7b6b425..8967b80 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,9 +5,11 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_system_colors file_selector_windows + flutter_secure_storage_windows screen_retriever_windows url_launcher_windows window_manager + window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST 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 f8a91f7..e3c83c9 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ