Compare commits

..

2 commits

Author SHA1 Message Date
3a1d884084 Update README.md 2026-04-28 22:38:54 -04:00
Zach Russell
ad8d4f36d9 Update windows build chain docs 2026-04-13 21:44:25 -06:00
216 changed files with 4476 additions and 6737 deletions

View file

@ -19,20 +19,13 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.41.9 flutter-version: 3.41.5
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: gomuks/go.mod go-version-file: gomuks/go.mod
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
mingw-w64-x86_64-gcc
- name: Go build - name: Go build
run: | run: |
cd gomuks/pkg/ffi cd gomuks/pkg/ffi
@ -45,13 +38,6 @@ jobs:
flutter pub run build_runner build flutter pub run build_runner build
flutter build windows --release flutter build windows --release
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
run: |
cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
- name: Upload exe zip - name: Upload exe zip
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:

View file

@ -5,11 +5,8 @@
"fluttertagger", "fluttertagger",
"Gomuks", "Gomuks",
"Homeserver", "Homeserver",
"Linkified",
"localpart", "localpart",
"msgtype",
"muks", "muks",
"prefs", "prefs"
"unban"
] ]
} }

View file

@ -1,70 +0,0 @@
# Development Documentation
## Build instructions
Build instructions can be found in [README.md](./README.md#build-it-yourself).
## Updating Gomuks
You can run the following command to update the Gomuks submodule:
```sh
git submodule update --remote
```
## Code Style
See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general rules. There are some extra rules detailed below:
### Controllers and Helpers ([Riverpod](https://pub.dev/packages/riverpod))
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
```dart
ref.watch(MyController.provider.notifier).helperMethod()
```
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
```dart
class MyController extends AsyncNotifier<DataThisControllerExposes> {
final SomeInputType input;
MyController(this.input);
@override
Future<DataThisControllerExposes> build() async {
return input.foo;
}
static final provider =
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
AuthorController.new,
);
}
```
Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`.
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
This project uses Flutter Hooks to help with boilerplate that StatefulWidgets create. Instead of using a StatefulWidget, we just use hooks like `useState` or `useEffect` in the build method of a `HookWidget`, which is a drop in replacement for `StatelessWidget`. If you need both a `WidgetRef` to watch providers, and access to hooks, use `HookConsumerWidget`.
### Models ([Freezed](https://pub.dev/packages/freezed))
We use Freezed for our models to avoid boilerplate and enforce an immutable style of state and data modeling throughout the code. See their documentation for more info, or see our existing models in `lib/models/`.
### Immutable Data Collections ([Fast Immutable Collections](https://pub.dev/packages/fast_immutable_collections))
When possible, use immutable collections instead of the mutable equivalent. For example, use `IMap` over `Map`, `IList` over `List`, `ISet` over `Set`. This matches the immutable style of Riverpod and Freezed.
### Don't create globals
When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method.
## LLM/AI Assisted Contributions
Largely LLM generated code is NOT allowed. All contributions should be written by humans, with minimal to no LLM assistance. Please disclose any usage of LLMs.
## Code of Conduct
All contributions must follow the [Federated Nexus Code of Conduct](https://federated.nexus/code/).

View file

@ -15,11 +15,15 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
## Progress ## Progress
- [ ] New logo
- [ ] Make context menus appear as bottom sheets on mobile
- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2
- [ ] Allow using remote Gomuks over websocket
- [ ] Platform Support - [ ] Platform Support
- [x] Linux - [x] Linux
- [x] Windows - [ ] Windows (WIP)
- [x] Android
- [ ] MacOS - [ ] MacOS
- [x] Android
- [ ] iOS - [ ] iOS
- [ ] Web (may not be possible) - [ ] Web (may not be possible)
- [x] Login - [x] Login
@ -39,7 +43,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] `matrix:` Uri - [x] `matrix:` Uri
- [x] Matrix.to link - [x] Matrix.to link
- [ ] From space - [ ] From space
- [ ] From directory - [ ] Exploring
- [x] Leaving - [x] Leaving
- [x] Subspaces - [x] Subspaces
- [x] Messages - [x] Messages
@ -50,7 +54,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] HTML/Markdown - [x] HTML/Markdown
- [x] Replies - [x] Replies
- [x] Choose ping on/off - [x] Choose ping on/off
- [x] Per message profiles - [ ] Per message profiles
- [ ] Attachments - [ ] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Mentions - [x] Mentions
@ -81,7 +85,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Users - [x] Users
- [x] Clickable - [x] Clickable
- [x] Rooms - [x] Rooms
- [ ] Clickable - [x] Clickable
- [x] Matrix URIs - [x] Matrix URIs
- [x] Matrix.to links - [x] Matrix.to links
- [x] Events - [x] Events
@ -107,23 +111,15 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] Reporting - [ ] Reporting
- [x] Events - [x] Events
- [ ] Rooms - [ ] Rooms
- [x] Member list - [ ] Notifications using UnifiedPush
- [x] Sort by power level
- [ ] Colors based off of power level
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites - [ ] Invites
- [ ] Settings ([#37](https://git.federated.nexus/Nexus/nexus/issues/37)) - [ ] Settings
- [ ] Matrix: URIs vs Matrix.to links - [ ] Matrix: URIs vs Matrix.to links
- [ ] Light/Dark mode - [ ] Light/Dark mode
- [ ] Remote Gomuks instance
- [ ] SSD or CSD - [ ] SSD or CSD
- [ ] Align your message bubbles to left or right
- [ ] Show media by default - [ ] Show media by default
- [ ] Dynamic Theming - [ ] Dynamic Theming
- [ ] Personas
- [ ] Setting per-message profiles for users (MSC4461)
- [ ] Explain how to send messages using a certain PMP
- [ ] Devices - [ ] Devices
- [ ] Viewing devices - [ ] Viewing devices
- [ ] Verifying devices - [ ] Verifying devices
@ -149,7 +145,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - [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) - [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` Or, try the Nix package: `nix run git+https://git.federated.nexus/Henry-Hiles/nexus`
## Build it yourself ## Build it yourself
@ -192,7 +188,7 @@ Similar prerequisites apply (Flutter, Git, Go, C toolchain, LLVM/libclang), but
First, clone and open the repo: First, clone and open the repo:
```sh ```sh
git clone --recurse-submodules https://git.federated.nexus/Nexus/nexus git clone --recurse-submodules https://git.federated.nexus/Henry-Hiles/nexus
cd nexus cd nexus
``` ```
@ -210,17 +206,10 @@ Generate Gomuks bindings:
dart scripts/generate.dart dart scripts/generate.dart
``` ```
> [!NOTE]
> If you are having issues with `stddef.h` not being found, try setting CPATH manually:
>
> ```sh
> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include"
> ```
Build generated files, and watch for new changes: Build generated files, and watch for new changes:
```sh ```sh
flutter pub run build_runner watch flutter pub run build_runner watch --delete-conflicting-outputs
``` ```
Run the app: Run the app:
@ -229,13 +218,6 @@ Run the app:
flutter run flutter run
``` ```
Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md).
## Community ## Community
Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. 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!

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -6,9 +6,4 @@
android:drawable="@drawable/ic_launcher_foreground" android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" /> android:inset="16%" />
</foreground> </foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

@ -1,22 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="512" width="100mm"
height="512" height="100mm"
viewBox="0 0 512 512" viewBox="0 0 100 100"
fill="none"
version="1.1" version="1.1"
id="svg11" id="svg1"
sodipodi:docname="background.svg" xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
inkscape:export-filename="background.png" sodipodi:docname="background.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"> xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
<sodipodi:namedview id="namedview1"
id="namedview11"
pagecolor="#505050" pagecolor="#505050"
bordercolor="#eeeeee" bordercolor="#eeeeee"
borderopacity="1" borderopacity="1"
@ -24,234 +23,40 @@
inkscape:pageopacity="0" inkscape:pageopacity="0"
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050" inkscape:deskcolor="#505050"
inkscape:zoom="0.69191503" inkscape:document-units="mm"
inkscape:cx="-71.540576" inkscape:zoom="1.0847363"
inkscape:cy="281.10388" inkscape:cx="57.156749"
inkscape:window-width="2544" inkscape:cy="214.33781"
inkscape:window-height="1363" inkscape:window-width="1904"
inkscape:window-height="971"
inkscape:window-x="35" inkscape:window-x="35"
inkscape:window-y="32" inkscape:window-y="32"
inkscape:window-maximized="0" inkscape:window-maximized="0"
inkscape:current-layer="svg11" /> inkscape:current-layer="layer1" /><defs
<defs id="defs1"><linearGradient
id="defs11"> id="linearGradient10"
<radialGradient inkscape:collect="always"><stop
id="paint0_radial_4033_8" style="stop-color:#c7a312;stop-opacity:1;"
cx="0" offset="0"
cy="0" id="stop10" /><stop
r="1" style="stop-color:#26a0b3;stop-opacity:1;"
gradientTransform="matrix(3.5,24.5214,-15.8099,2.26053,80,52.9904)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop10" />
<stop
offset="1" offset="1"
stop-color="#3584E4" id="stop11" /></linearGradient><linearGradient
id="stop11" /> inkscape:collect="always"
</radialGradient> xlink:href="#linearGradient10"
<mask id="linearGradient11"
id="mask0_4033_8" x1="20.031296"
maskUnits="userSpaceOnUse" y1="32.697563"
x="56" x2="90.709213"
y="46" y2="66.3423"
width="21" gradientUnits="userSpaceOnUse" /></defs><g
height="36"> inkscape:label="Layer 1"
<path inkscape:groupmode="layer"
d="m 76.4223,63.1501 c 0.3482,0.5123 0.3457,1.1859 -0.0063,1.6956 L 65.0175,81.3524 C 64.7375,81.7579 64.2761,82 63.7832,82 h -5.9804 c -1.1981,0 -1.9127,-1.3352 -1.2481,-2.332 l 9.8906,-14.8359 c 0.3359,-0.5039 0.3359,-1.1603 0,-1.6641 L 57.0729,49.1094 C 56.1869,47.7803 57.1396,46 58.737,46 h 5.2334 c 0.4967,0 0.9613,0.2459 1.2405,0.6568 z" id="layer1"><rect
fill="#2779dd" style="fill:url(#linearGradient11);fill-opacity:1;stroke:none;stroke-width:7.99999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path9" /> id="rect10"
</mask> width="100"
<clipPath height="100"
clipPathUnits="userSpaceOnUse" x="0"
id="clipPath11"> y="0"
<rect ry="28.294127" /></g></svg>
width="128"
height="128"
fill="#ffffff"
id="rect12"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath12">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect13"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath13">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect14"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath14">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect15"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath15">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect16"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath16">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect17"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<radialGradient
id="paint0_radial_4033_8-3"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(3.5,24.5214,-15.8099,2.26053,174.26633,65.9904)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop10-6" />
<stop
offset="1"
stop-color="#3584E4"
id="stop11-7" />
</radialGradient>
<radialGradient
id="paint0_radial_4033_8-35"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(3.5,24.5214,-15.8099,2.26053,80,52.9904)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop10-62" />
<stop
offset="1"
stop-color="#3584E4"
id="stop11-9" />
</radialGradient>
<mask
id="mask0_4033_8-1"
maskUnits="userSpaceOnUse"
x="56"
y="46"
width="21"
height="36">
<path
d="m 76.4223,63.1501 c 0.3482,0.5123 0.3457,1.1859 -0.0063,1.6956 L 65.0175,81.3524 C 64.7375,81.7579 64.2761,82 63.7832,82 h -5.9804 c -1.1981,0 -1.9127,-1.3352 -1.2481,-2.332 l 9.8906,-14.8359 c 0.3359,-0.5039 0.3359,-1.1603 0,-1.6641 L 57.0729,49.1094 C 56.1869,47.7803 57.1396,46 58.737,46 h 5.2334 c 0.4967,0 0.9613,0.2459 1.2405,0.6568 z"
fill="#2779dd"
id="path9-2" />
</mask>
<radialGradient
id="paint0_radial_4033_8-9"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(14,98.0856,-63.2396,9.04212,320,211.9616)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop10-3" />
<stop
offset="1"
stop-color="#3584E4"
id="stop11-6" />
</radialGradient>
</defs>
<rect
width="512"
height="512"
fill="#ffffff"
id="rect1"
x="0"
y="0"
style="stroke-width:4" />
<rect
x="-1.5384758"
y="-122.66472"
width="35.5569"
height="291.86301"
transform="matrix(3.4641016,2,-2,3.4641016,0,0)"
fill="#9141ac"
id="rect2"
clip-path="url(#clipPath16)" />
<rect
x="34.018467"
y="-122.66468"
width="26.6677"
height="291.86301"
transform="matrix(3.4641016,2,-2,3.4641016,0,0)"
fill="#62a0ea"
id="rect3"
clip-path="url(#clipPath15)" />
<rect
x="60.68605"
y="-122.66468"
width="26.6677"
height="291.86301"
transform="matrix(3.4641016,2,-2,3.4641016,0,0)"
fill="#57e389"
id="rect4"
clip-path="url(#clipPath14)" />
<rect
x="87.353859"
y="-122.66468"
width="26.6677"
height="291.86301"
transform="matrix(3.4641016,2,-2,3.4641016,0,0)"
fill="#f5c211"
id="rect5"
clip-path="url(#clipPath13)" />
<rect
x="114.02161"
y="-122.66477"
width="26.6677"
height="291.86301"
transform="matrix(3.4641016,2,-2,3.4641016,0,0)"
fill="#ff7800"
id="rect6"
clip-path="url(#clipPath12)" />
<rect
x="140.68942"
y="-122.66477"
width="35.5569"
height="291.86301"
transform="matrix(3.4641016,2,-2,3.4641016,0,0)"
fill="#ed333b"
id="rect7"
clip-path="url(#clipPath11)" />
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

View file

@ -1,19 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="512" width="100mm"
height="512" height="100mm"
viewBox="0 0 512 512" viewBox="0 0 100 100"
fill="none"
version="1.1" version="1.1"
id="svg11" id="svg1"
sodipodi:docname="foreground.svg" xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="nexus.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"> xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
<sodipodi:namedview id="namedview1"
id="namedview11"
pagecolor="#505050" pagecolor="#505050"
bordercolor="#eeeeee" bordercolor="#eeeeee"
borderopacity="1" borderopacity="1"
@ -21,137 +22,105 @@
inkscape:pageopacity="0" inkscape:pageopacity="0"
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050" inkscape:deskcolor="#505050"
inkscape:zoom="0.87695313" inkscape:document-units="mm"
inkscape:cx="152.23163" inkscape:zoom="1.0847363"
inkscape:cy="347.22494" inkscape:cx="58.07863"
inkscape:window-width="2544" inkscape:cy="214.3378"
inkscape:window-height="1363" inkscape:window-width="1896"
inkscape:window-height="987"
inkscape:window-x="35" inkscape:window-x="35"
inkscape:window-y="32" inkscape:window-y="32"
inkscape:window-maximized="0" inkscape:window-maximized="0"
inkscape:current-layer="svg11" /> inkscape:current-layer="layer1" /><defs
<path id="defs1" /><g
d="m 256,92 c 90.5748,0 164,73.4252 164,164 0,90.5748 -73.4252,164 -164,164 -34.5828,0 -66.6592,-10.712 -93.1092,-28.9884 l -39.2072,8.7656 c -6.8668,1.5348 -12.9952,-4.594 -11.4608,-11.4608 l 8.7616,-39.2108 C 102.7104,322.6564 92,290.5808 92,256 92,165.4252 165.4252,92 256,92 Z" inkscape:label="Layer 1"
fill="#ffffff" inkscape:groupmode="layer"
id="path7" id="layer1"><path
style="stroke-width:4" /> style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
<path d="M 19.377906,68.106953 80.937684,32.43771"
d="m 304.9188,251.4672 c 1.8572,2.732 1.844,6.3248 -0.0332,9.0432 L 260.6664,324.546 C 259.1728,326.7088 256.712,328 254.0836,328 H 234.948 c -6.3896,0 -10.2004,-7.1212 -6.6564,-12.4376 l 36.75,-55.1248 c 1.7916,-2.6872 1.7916,-6.188 0,-8.8752 l -36.75,-55.1248 C 224.7476,191.1212 228.5584,184 234.948,184 h 19.8748 c 2.6492,0 5.1268,1.3116 6.616,3.5028 z" id="path10" /><path
fill="url(#paint0_radial_4033_8)" style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path8" d="m 19.044488,32.469148 61.61782,35.569625"
style="fill:url(#paint0_radial_4033_8);stroke-width:4" /> id="path9" /><path
<g style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
mask="url(#mask0_4033_8)" d="M 50,85.574911 V 14.425087"
id="g9" id="path8" /><circle
transform="scale(4)"> style="fill:none;stroke:#ffffff;stroke-width:7.11498;stroke-linecap:round;stroke-linejoin:round"
<rect id="path1"
x="52" cx="50"
y="46" cy="50"
width="17" r="35.574913" /><circle
height="4" style="fill:#09bd05;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
fill="#2779dd" id="path2"
id="rect9" /> cx="50"
</g> cy="84.604881"
<defs r="8.2508707" /><circle
id="defs11"> style="fill:#fe1e24;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
<radialGradient id="circle2"
id="paint0_radial_4033_8" cx="50"
cx="0" cy="15.395123"
cy="0" r="8.2508707" /><circle
r="1" style="fill:#fe941d;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
gradientTransform="matrix(14,98.0856,-63.2396,9.04212,320,211.9616)" id="circle3"
gradientUnits="userSpaceOnUse"> cx="-68.30127"
<stop cy="52.906147"
stop-color="#72AAEE" r="8.2508707"
id="stop10" /> transform="rotate(-120)" /><circle
<stop style="fill:#001996;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
offset="1" id="circle4"
stop-color="#3584E4" cx="-68.30127"
id="stop11" /> cy="-16.30361"
</radialGradient> r="8.2508707"
<mask transform="rotate(-120)" /><circle
id="mask0_4033_8" style="fill:#ffff04;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
maskUnits="userSpaceOnUse" id="circle5"
x="56" cx="-18.301271"
y="46" cy="102.90615"
width="21" r="8.2508707"
height="36"> transform="rotate(-60)" /><circle
<path style="fill:#770287;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
d="m 76.4223,63.1501 c 0.3482,0.5123 0.3457,1.1859 -0.0063,1.6956 L 65.0175,81.3524 C 64.7375,81.7579 64.2761,82 63.7832,82 h -5.9804 c -1.1981,0 -1.9127,-1.3352 -1.2481,-2.332 l 9.8906,-14.8359 c 0.3359,-0.5039 0.3359,-1.1603 0,-1.6641 L 57.0729,49.1094 C 56.1869,47.7803 57.1396,46 58.737,46 h 5.2334 c 0.4967,0 0.9613,0.2459 1.2405,0.6568 z" id="circle6"
fill="#2779dd" cx="-18.301271"
id="path9" /> cy="33.696392"
</mask> r="8.2508707"
<clipPath transform="rotate(-60)" /><circle
clipPathUnits="userSpaceOnUse" style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:6.75;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="clipPath11"> id="path7"
<rect cx="50"
width="128" cy="50"
height="128" r="9.7918472" /><g
fill="#ffffff" inkscape:label="Layer 1"
id="rect12" id="layer1-3"
x="0" transform="matrix(0.08246781,0,0,0.08246781,38.828362,38.828362)"
y="0" style="stroke:#ffffff"><text
transform="rotate(-30)" /> xml:space="preserve"
</clipPath> style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:96.3615px;line-height:0;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif, Heavy';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:end;text-anchor:end;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
<clipPath x="-305.64749"
clipPathUnits="userSpaceOnUse" y="194.14493"
id="clipPath12"> id="text2819"><tspan
<rect sodipodi:role="line"
width="128" id="tspan2817"
height="128" style="stroke:#ffffff;stroke-width:0"
fill="#ffffff" x="-305.64749"
id="rect13" y="194.14493" /></text><circle
x="0" style="fill:#354b5f;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
y="0" id="path342"
transform="rotate(-30)" /> cx="135.46666"
</clipPath> cy="135.46666"
<clipPath r="135.46666" /><text
clipPathUnits="userSpaceOnUse" xml:space="preserve"
id="clipPath13"> style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:96.3615px;line-height:0;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif, Heavy';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:end;text-anchor:end;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
<rect x="-305.64749"
width="128" y="194.14493"
height="128" id="text2819-3"><tspan
fill="#ffffff" sodipodi:role="line"
id="rect14" id="tspan2817-5"
x="0" style="stroke:#ffffff;stroke-width:0"
y="0" x="-305.64749"
transform="rotate(-30)" /> y="194.14493" /></text><g
</clipPath> aria-label=""
<clipPath id="text2827-6"
clipPathUnits="userSpaceOnUse" style="font-size:132.452px;line-height:0;font-family:PowerlineSymbols;-inkscape-font-specification:'PowerlineSymbols, Normal';text-align:end;text-anchor:end;fill:#4e94e4;fill-opacity:1;stroke:#ffffff;stroke-width:0"><path
id="clipPath14"> d="M 95.096912,209.8167 143.88912,135.46666 95.096912,61.116629 h 32.818568 l 47.92093,74.350031 -47.92093,74.35004 z"
<rect id="path2883-2"
width="128" style="fill:#4e94e4;fill-opacity:1;stroke:#ffffff;stroke-width:0" /></g></g></g></svg>
height="128"
fill="#ffffff"
id="rect15"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath15">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect16"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath16">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect17"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -1,22 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="512" width="100mm"
height="512" height="100mm"
viewBox="0 0 512 512" viewBox="0 0 100 100"
fill="none"
version="1.1" version="1.1"
id="svg35" id="svg1"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="icon.svg" sodipodi:docname="icon.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
inkscape:export-filename="icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"> xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
<sodipodi:namedview id="namedview1"
id="namedview35"
pagecolor="#505050" pagecolor="#505050"
bordercolor="#eeeeee" bordercolor="#eeeeee"
borderopacity="1" borderopacity="1"
@ -24,311 +23,128 @@
inkscape:pageopacity="0" inkscape:pageopacity="0"
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050" inkscape:deskcolor="#505050"
inkscape:zoom="1.321682" inkscape:document-units="mm"
inkscape:cx="69.608271" inkscape:zoom="1.0847363"
inkscape:cy="120.67956" inkscape:cx="57.61769"
inkscape:window-width="2544" inkscape:cy="214.33781"
inkscape:window-height="1363" inkscape:window-width="1896"
inkscape:window-height="963"
inkscape:window-x="35" inkscape:window-x="35"
inkscape:window-y="32" inkscape:window-y="32"
inkscape:window-maximized="0" inkscape:window-maximized="0"
inkscape:current-layer="svg35" /> inkscape:current-layer="layer1" /><defs
<mask id="defs1"><linearGradient
id="mask0_4023_558" id="linearGradient10"
maskUnits="userSpaceOnUse" inkscape:collect="always"><stop
x="12" style="stop-color:#c7a312;stop-opacity:1;"
y="100" offset="0"
width="88" id="stop10" /><stop
height="16"> style="stop-color:#26a0b3;stop-opacity:1;"
<path
d="m 100,104 c 0,6.627 -5.3726,12 -12,12 H 24 c -6.6274,0 -12,-5.373 -12,-12 v -4 c 0,6.627 5.3726,12 12,12 h 64 c 6.6274,0 12,-5.373 12,-12 z"
fill="#d9d9d9"
id="path6" />
</mask>
<mask
id="mask1_4023_558"
maskUnits="userSpaceOnUse"
x="77"
y="27"
width="21"
height="36">
<path
d="m 97.4223,44.1501 c 0.3482,0.5122 0.3457,1.1859 -0.0063,1.6956 L 86.0175,62.3523 C 85.7375,62.7579 85.2761,63 84.7832,63 h -5.9804 c -1.1981,0 -1.9127,-1.3352 -1.2481,-2.3321 l 9.8906,-14.8358 c 0.3359,-0.5039 0.3359,-1.1603 0,-1.6642 L 78.0729,30.1094 C 77.1869,28.7803 78.1396,27 79.737,27 h 5.2334 c 0.4967,0 0.9613,0.2459 1.2405,0.6567 z"
fill="#2779dd"
id="path14" />
</mask>
<g
id="g36"
transform="scale(4)">
<g
clip-path="url(#clip0_4023_558)"
id="g6">
<rect
x="62.0205"
y="-52.425598"
width="24"
height="197"
transform="rotate(30,62.0205,-52.4256)"
fill="#9141ac"
id="rect1" />
<rect
x="82.805099"
y="-40.425598"
width="18"
height="197"
transform="rotate(30,82.8051,-40.4256)"
fill="#62a0ea"
id="rect2" />
<rect
x="98.3936"
y="-31.4256"
width="18"
height="197"
transform="rotate(30,98.3936,-31.4256)"
fill="#57e389"
id="rect3" />
<rect
x="113.982"
y="-22.4256"
width="18"
height="197"
transform="rotate(30,113.982,-22.4256)"
fill="#f5c211"
id="rect4" />
<rect
x="129.57001"
y="-13.4256"
width="18"
height="197"
transform="rotate(30,129.57,-13.4256)"
fill="#ff7800"
id="rect5" />
<rect
x="145.159"
y="-4.4256301"
width="24"
height="197"
transform="rotate(30,145.159,-4.42563)"
fill="#ed333b"
id="rect6" />
</g>
<g
mask="url(#mask0_4023_558)"
id="g10">
<path
d="m 12,100 h 24 v 16 H 12 Z"
fill="url(#paint0_linear_4023_558)"
id="path7"
style="fill:url(#paint0_linear_4023_558)" />
<path
d="m 12,100 h 5 v 16 h -5 z"
fill="url(#paint1_linear_4023_558)"
id="path8"
style="fill:url(#paint1_linear_4023_558)" />
<rect
x="36"
y="100"
width="21"
height="16"
fill="#ca9005"
id="rect8" />
<rect
x="57"
y="100"
width="21"
height="16"
fill="#c64600"
id="rect9" />
<rect
x="78"
y="100"
width="22"
height="16"
fill="url(#paint2_linear_4023_558)"
id="rect10"
style="fill:url(#paint2_linear_4023_558)" />
</g>
<rect
opacity="0.2"
x="24.5"
y="110.5"
width="63"
height="1"
stroke="url(#paint3_linear_4023_558)"
id="rect11"
style="stroke:url(#paint3_linear_4023_558)" />
<path
d="m 85,4 c 22.644,0 41,18.3563 41,41 v 4 c 0,22.6437 -18.356,41 -41,41 -8.3322,0 -16.0825,-2.4875 -22.5526,-6.7576 -0.4653,-0.3071 -1.0341,-0.4203 -1.5783,-0.2987 l -8.8369,1.9749 C 52.0134,84.9228 52,84.9395 52,84.9588 52,84.9816 51.9816,85 51.9588,85 h -0.4617 c -0.0787,0.0036 -0.1564,0.004 -0.2334,0 H 51 c -1.1046,0 -2,-0.8954 -2,-2 v -0.2617 c -0.0043,-0.0811 -0.0042,-0.1632 0,-0.2461 V 78.9749 C 49,78.7126 49.2126,78.5 49.4749,78.5 c 0.2224,0 0.4151,-0.1543 0.4635,-0.3714 l 1.117,-4.9987 C 51.177,72.5857 51.0638,72.017 50.7567,71.5517 46.487,65.0818 44,57.3317 44,49 V 45 C 44,22.3563 62.3563,4 85,4 Z"
fill="url(#paint4_linear_4023_558)"
id="path11"
style="fill:url(#paint4_linear_4023_558)" />
<path
d="m 85,4 c 22.644,0 41,18.3563 41,41 0,22.6437 -18.356,41 -41,41 -8.6457,0 -16.6648,-2.6781 -23.2773,-7.2471 l -9.8018,2.1914 c -1.7167,0.3836 -3.2488,-1.1485 -2.8652,-2.8652 l 2.1904,-9.8027 C 46.6776,61.6641 44,53.6452 44,45 44,22.3563 62.3563,4 85,4 Z"
fill="#ffffff"
id="path12" />
<path
d="m 97.2297,43.8668 c 0.4642,0.683 0.4609,1.5812 -0.0083,2.2608 L 86.1666,62.1365 C 85.7932,62.6772 85.178,63 84.5209,63 H 79.737 c -1.5974,0 -2.5502,-1.7803 -1.6641,-3.1094 l 9.1875,-13.7812 c 0.4478,-0.6718 0.4478,-1.547 0,-2.2188 L 78.0729,30.1094 C 77.1868,28.7803 78.1396,27 79.737,27 h 4.9687 c 0.6623,0 1.2817,0.3279 1.654,0.8757 z"
fill="url(#paint5_radial_4023_558)"
id="path13"
style="fill:url(#paint5_radial_4023_558)" />
<g
mask="url(#mask1_4023_558)"
id="g14">
<rect
x="73"
y="27"
width="17"
height="4"
fill="#2779dd"
id="rect14" />
</g>
</g>
<defs
id="defs35">
<linearGradient
id="paint0_linear_4023_558"
x1="34"
y1="108"
x2="12"
y2="108"
gradientUnits="userSpaceOnUse">
<stop
offset="0.401381"
stop-color="#26A269"
id="stop14" />
<stop
offset="0.801049"
stop-color="#8AEB52"
id="stop15" />
<stop
offset="1" offset="1"
stop-color="#26A269" id="stop11" /></linearGradient><linearGradient
id="stop16" /> inkscape:collect="always"
</linearGradient> xlink:href="#linearGradient10"
<linearGradient id="linearGradient11"
id="paint1_linear_4023_558" x1="20.031296"
x1="12" y1="32.697563"
y1="108" x2="90.709213"
x2="17" y2="66.3423"
y2="108" gradientUnits="userSpaceOnUse" /></defs><g
gradientUnits="userSpaceOnUse"> inkscape:label="Layer 1"
<stop inkscape:groupmode="layer"
stop-color="#1A5FB4" id="layer1"><rect
id="stop17" /> style="fill:url(#linearGradient11);fill-opacity:1;stroke:none;stroke-width:7.99999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
<stop id="rect10"
offset="1" width="100"
stop-color="#35E0F6" height="100"
id="stop18" /> x="0"
</linearGradient> y="0"
<linearGradient ry="28.294127" /><path
id="paint2_linear_4023_558" style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
x1="100" d="M 19.377906,68.106953 80.937684,32.43771"
y1="108" id="path10" /><path
x2="78" style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
y2="108" d="m 19.044488,32.469148 61.61782,35.569625"
gradientUnits="userSpaceOnUse"> id="path9" /><path
<stop style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
stop-color="#A51D2D" d="M 50,85.574911 V 14.425087"
id="stop19" /> id="path8" /><circle
<stop style="fill:none;stroke:#ffffff;stroke-width:7.11498;stroke-linecap:round;stroke-linejoin:round"
offset="0.195858" id="path1"
stop-color="#E5673C" cx="50"
id="stop20" /> cy="50"
<stop r="35.574913" /><circle
offset="0.5983" style="fill:#09bd05;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
stop-color="#A51D2D" id="path2"
id="stop21" /> cx="50"
</linearGradient> cy="84.604881"
<linearGradient r="8.2508707" /><circle
id="paint3_linear_4023_558" style="fill:#fe1e24;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
x1="88" id="circle2"
y1="111.329" cx="50"
x2="24" cy="15.395123"
y2="111.329" r="8.2508707" /><circle
gradientUnits="userSpaceOnUse"> style="fill:#fe941d;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
<stop id="circle3"
offset="0.102371" cx="-68.30127"
stop-color="white" cy="52.906147"
stop-opacity="0" r="8.2508707"
id="stop22" /> transform="rotate(-120)" /><circle
<stop style="fill:#001996;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
offset="0.253808" id="circle4"
stop-color="white" cx="-68.30127"
id="stop23" /> cy="-16.30361"
<stop r="8.2508707"
offset="0.747697" transform="rotate(-120)" /><circle
stop-color="white" style="fill:#ffff04;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="stop24" /> id="circle5"
<stop cx="-18.301271"
offset="0.895556" cy="102.90615"
stop-color="white" r="8.2508707"
stop-opacity="0" transform="rotate(-60)" /><circle
id="stop25" /> style="fill:#770287;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
</linearGradient> id="circle6"
<linearGradient cx="-18.301271"
id="paint4_linear_4023_558" cy="33.696392"
x1="44" r="8.2508707"
y1="48.036098" transform="rotate(-60)" /><circle
x2="126" style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:6.75;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
y2="48.036098" id="path7"
gradientUnits="userSpaceOnUse"> cx="50"
<stop cy="50"
stop-color="#DBEBF4" r="9.7918472" /><g
id="stop26" /> inkscape:label="Layer 1"
<stop id="layer1-3"
offset="0.147387" transform="matrix(0.08246781,0,0,0.08246781,38.828362,38.828362)"
stop-color="#B1D4E7" style="stroke:#ffffff"><text
id="stop27" /> xml:space="preserve"
<stop style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:96.3615px;line-height:0;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif, Heavy';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:end;text-anchor:end;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
offset="0.186621" x="-305.64749"
stop-color="#8DC0DC" y="194.14493"
id="stop28" /> id="text2819"><tspan
<stop sodipodi:role="line"
offset="0.203755" id="tspan2817"
stop-color="#49AEE7" style="stroke:#ffffff;stroke-width:0"
id="stop29" /> x="-305.64749"
<stop y="194.14493" /></text><circle
offset="0.276122" style="fill:#354b5f;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
stop-color="#7AB5D7" id="path342"
id="stop30" /> cx="135.46666"
<stop cy="135.46666"
offset="0.399628" r="135.46666" /><text
stop-color="#B3D6E7" xml:space="preserve"
id="stop31" /> style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:96.3615px;line-height:0;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif, Heavy';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:end;text-anchor:end;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
<stop x="-305.64749"
offset="0.507537" y="194.14493"
stop-color="#B3D6E7" id="text2819-3"><tspan
id="stop32" /> sodipodi:role="line"
<stop id="tspan2817-5"
offset="1" style="stroke:#ffffff;stroke-width:0"
stop-color="#DBEBF4" x="-305.64749"
id="stop33" /> y="194.14493" /></text><g
</linearGradient> aria-label=""
<radialGradient id="text2827-6"
id="paint5_radial_4023_558" style="font-size:132.452px;line-height:0;font-family:PowerlineSymbols;-inkscape-font-specification:'PowerlineSymbols, Normal';text-align:end;text-anchor:end;fill:#4e94e4;fill-opacity:1;stroke:#ffffff;stroke-width:0"><path
cx="0" d="M 95.096912,209.8167 143.88912,135.46666 95.096912,61.116629 h 32.818568 l 47.92093,74.350031 -47.92093,74.35004 z"
cy="0" id="path2883-2"
r="1" style="fill:#4e94e4;fill-opacity:1;stroke:#ffffff;stroke-width:0" /></g></g></g></svg>
gradientTransform="matrix(3.5,24.5214,-15.8099,2.26053,101,33.9904)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop34" />
<stop
offset="1"
stop-color="#3584E4"
id="stop35" />
</radialGradient>
<clipPath
id="clip0_4023_558">
<rect
x="12"
y="36"
width="88"
height="80"
rx="12"
fill="#ffffff"
id="rect35" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,156 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
version="1.1"
id="svg11"
sodipodi:docname="mobile.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview11"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="30.03125"
inkscape:cx="14.51821"
inkscape:cy="11.038502"
inkscape:window-width="2544"
inkscape:window-height="1363"
inkscape:window-x="35"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="svg11" />
<g
id="g11"
transform="scale(4)">
<g
clip-path="url(#clip0_4033_8)"
id="g10">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect1"
x="0"
y="0" />
<rect
x="60"
y="-107"
width="35.5569"
height="291.86301"
transform="rotate(30,60,-107)"
fill="#9141ac"
id="rect2" />
<rect
x="90.793198"
y="-89.221497"
width="26.6677"
height="291.86301"
transform="rotate(30,90.7932,-89.2215)"
fill="#62a0ea"
id="rect3" />
<rect
x="113.888"
y="-75.887703"
width="26.6677"
height="291.86301"
transform="rotate(30,113.888,-75.8877)"
fill="#57e389"
id="rect4" />
<rect
x="136.983"
y="-62.553799"
width="26.6677"
height="291.86301"
transform="rotate(30,136.983,-62.5538)"
fill="#f5c211"
id="rect5" />
<rect
x="160.078"
y="-49.220001"
width="26.6677"
height="291.86301"
transform="rotate(30,160.078,-49.22)"
fill="#ff7800"
id="rect6" />
<rect
x="183.173"
y="-35.886101"
width="35.5569"
height="291.86301"
transform="rotate(30,183.173,-35.8861)"
fill="#ed333b"
id="rect7" />
<path
d="m 64,23 c 22.6437,0 41,18.3563 41,41 0,22.6437 -18.3563,41 -41,41 -8.6457,0 -16.6648,-2.678 -23.2773,-7.2471 l -9.8018,2.1914 c -1.7167,0.3837 -3.2488,-1.1485 -2.8652,-2.8652 l 2.1904,-9.8027 C 25.6776,80.6641 23,72.6452 23,64 23,41.3563 41.3563,23 64,23 Z"
fill="#ffffff"
id="path7" />
<path
d="m 76.2297,62.8668 c 0.4643,0.683 0.461,1.5812 -0.0083,2.2608 L 65.1666,81.1365 C 64.7932,81.6772 64.178,82 63.5209,82 H 58.737 c -1.5974,0 -2.5501,-1.7803 -1.6641,-3.1094 l 9.1875,-13.7812 c 0.4479,-0.6718 0.4479,-1.547 0,-2.2188 L 57.0729,49.1094 C 56.1869,47.7803 57.1396,46 58.737,46 h 4.9687 c 0.6623,0 1.2817,0.3279 1.654,0.8757 z"
fill="url(#paint0_radial_4033_8)"
id="path8"
style="fill:url(#paint0_radial_4033_8)" />
<mask
id="mask0_4033_8"
maskUnits="userSpaceOnUse"
x="56"
y="46"
width="21"
height="36">
<path
d="m 76.4223,63.1501 c 0.3482,0.5123 0.3457,1.1859 -0.0063,1.6956 L 65.0175,81.3524 C 64.7375,81.7579 64.2761,82 63.7832,82 h -5.9804 c -1.1981,0 -1.9127,-1.3352 -1.2481,-2.332 l 9.8906,-14.8359 c 0.3359,-0.5039 0.3359,-1.1603 0,-1.6641 L 57.0729,49.1094 C 56.1869,47.7803 57.1396,46 58.737,46 h 5.2334 c 0.4967,0 0.9613,0.2459 1.2405,0.6568 z"
fill="#2779dd"
id="path9" />
</mask>
<g
mask="url(#mask0_4033_8)"
id="g9">
<rect
x="52"
y="46"
width="17"
height="4"
fill="#2779dd"
id="rect9" />
</g>
</g>
</g>
<defs
id="defs11">
<radialGradient
id="paint0_radial_4033_8"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(3.5,24.5214,-15.8099,2.26053,80,52.9904)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop10" />
<stop
offset="1"
stop-color="#3584E4"
id="stop11" />
</radialGradient>
<clipPath
id="clip0_4033_8">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect11"
x="0"
y="0" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,178 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
version="1.1"
id="svg11"
sodipodi:docname="monochrome.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
inkscape:export-filename="foreground.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview11"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="1.240199"
inkscape:cx="156.02335"
inkscape:cy="321.3194"
inkscape:window-width="2544"
inkscape:window-height="1363"
inkscape:window-x="35"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="svg11" />
<path
d="m 256,92 c 90.5748,0 164,73.4252 164,164 0,90.5748 -73.4252,164 -164,164 -34.5828,0 -66.6592,-10.712 -93.1092,-28.9884 l -39.2072,8.7656 c -6.8668,1.5348 -12.9952,-4.594 -11.4608,-11.4608 l 8.7616,-39.2108 C 102.7104,322.6564 92,290.5808 92,256 92,165.4252 165.4252,92 256,92 Z"
fill="#ffffff"
id="path7"
style="stroke-width:4"
clip-path="url(#clipPath1)"
inkscape:path-effect="#path-effect1" />
<defs
id="defs11">
<inkscape:path-effect
effect="powerclip"
message=""
id="path-effect1"
is_visible="true"
lpeversion="1"
inverse="true"
flatten="false"
hide_clip="false" />
<radialGradient
id="paint0_radial_4033_8"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(14,98.0856,-63.2396,9.04212,320,211.9616)"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#72AAEE"
id="stop10" />
<stop
offset="1"
stop-color="#3584E4"
id="stop11" />
</radialGradient>
<mask
id="mask0_4033_8"
maskUnits="userSpaceOnUse"
x="56"
y="46"
width="21"
height="36">
<path
d="m 76.4223,63.1501 c 0.3482,0.5123 0.3457,1.1859 -0.0063,1.6956 L 65.0175,81.3524 C 64.7375,81.7579 64.2761,82 63.7832,82 h -5.9804 c -1.1981,0 -1.9127,-1.3352 -1.2481,-2.332 l 9.8906,-14.8359 c 0.3359,-0.5039 0.3359,-1.1603 0,-1.6641 L 57.0729,49.1094 C 56.1869,47.7803 57.1396,46 58.737,46 h 5.2334 c 0.4967,0 0.9613,0.2459 1.2405,0.6568 z"
fill="#2779dd"
id="path9" />
</mask>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath11">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect12"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath12">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect13"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath13">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect14"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath14">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect15"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath15">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect16"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath16">
<rect
width="128"
height="128"
fill="#ffffff"
id="rect17"
x="0"
y="0"
transform="rotate(-30)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath1">
<path
d="m 304.9188,251.4672 c 1.8572,2.732 1.844,6.3248 -0.0332,9.0432 L 260.6664,324.546 C 259.1728,326.7088 256.712,328 254.0836,328 H 234.948 c -6.3896,0 -10.2004,-7.1212 -6.6564,-12.4376 l 36.75,-55.1248 c 1.7916,-2.6872 1.7916,-6.188 0,-8.8752 l -36.75,-55.1248 C 224.7476,191.1212 228.5584,184 234.948,184 h 19.8748 c 2.6492,0 5.1268,1.3116 6.616,3.5028 z"
fill="url(#paint0_radial_4033_8)"
id="path1"
style="display:none;fill:url(#radialGradient1);stroke-width:4" />
<path
id="lpe_path-effect1"
style="fill:url(#radialGradient1);stroke-width:4"
class="powerclip"
d="M 87,87 H 425 V 425 H 87 Z m 217.9188,164.4672 -43.48,-63.9644 C 259.9496,185.3116 257.472,184 254.8228,184 H 234.948 c -6.3896,0 -10.2004,7.1212 -6.6564,12.4376 l 36.75,55.1248 c 1.7916,2.6872 1.7916,6.188 0,8.8752 l -36.75,55.1248 C 224.7476,320.8788 228.5584,328 234.948,328 h 19.1356 c 2.6284,0 5.0892,-1.2912 6.5828,-3.454 l 44.2192,-64.0356 c 1.8772,-2.7184 1.8904,-6.3112 0.0332,-9.0432 z" />
</clipPath>
<radialGradient
inkscape:collect="always"
xlink:href="#paint0_radial_4033_8"
id="radialGradient1"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(14,98.0856,-63.2396,9.04212,320,211.9616)"
cx="0"
cy="0"
r="1" />
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

BIN
assets/reactions.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 423 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 425 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="smallerForeground.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="1.0847363"
inkscape:cx="58.078632"
inkscape:cy="213.41592"
inkscape:window-width="2544"
inkscape:window-height="1363"
inkscape:window-x="35"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:5.41161;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 29.285638,62.248476 70.927843,38.119963"
id="path10" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:5.41161;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 29.060097,38.141229 70.741564,62.202356"
id="path9" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:5.41161;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 50,74.064703 V 25.935297"
id="path8" /><circle
style="fill:none;stroke:#ffffff;stroke-width:4.81294;stroke-linecap:round;stroke-linejoin:round"
id="path1"
cx="50"
cy="50"
r="24.064703" /><circle
style="fill:#09bd05;fill-opacity:1;stroke:#ffffff;stroke-width:3.38226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path2"
cx="50"
cy="73.408524"
r="5.5813141" /><circle
style="fill:#fe1e24;fill-opacity:1;stroke:#ffffff;stroke-width:3.38226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="circle2"
cx="50"
cy="26.59148"
r="5.5813141" /><circle
style="fill:#fe941d;fill-opacity:1;stroke:#ffffff;stroke-width:3.38226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="circle3"
cx="-68.30127"
cy="41.709789"
r="5.5813141"
transform="rotate(-120)" /><circle
style="fill:#001996;fill-opacity:1;stroke:#ffffff;stroke-width:3.38226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="circle4"
cx="-68.30127"
cy="-5.1072531"
r="5.5813141"
transform="rotate(-120)" /><circle
style="fill:#ffff04;fill-opacity:1;stroke:#ffffff;stroke-width:3.38226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="circle5"
cx="-18.301271"
cy="91.709793"
r="5.5813141"
transform="rotate(-60)" /><circle
style="fill:#770287;fill-opacity:1;stroke:#ffffff;stroke-width:3.38226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="circle6"
cx="-18.301271"
cy="44.89275"
r="5.5813141"
transform="rotate(-60)" /><circle
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:4.56605;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path7"
cx="50"
cy="50"
r="6.6237097" /><g
inkscape:label="Layer 1"
id="layer1-3"
transform="matrix(0.05578547,0,0,0.05578547,42.442929,42.442929)"
style="stroke:#ffffff"><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:96.3615px;line-height:0;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif, Heavy';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:end;text-anchor:end;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
x="-305.64749"
y="194.14493"
id="text2819"><tspan
sodipodi:role="line"
id="tspan2817"
style="stroke:#ffffff;stroke-width:0"
x="-305.64749"
y="194.14493" /></text><circle
style="fill:#354b5f;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
id="path342"
cx="135.46666"
cy="135.46666"
r="135.46666" /><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:900;font-stretch:normal;font-size:96.3615px;line-height:0;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif, Heavy';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:end;text-anchor:end;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
x="-305.64749"
y="194.14493"
id="text2819-3"><tspan
sodipodi:role="line"
id="tspan2817-5"
style="stroke:#ffffff;stroke-width:0"
x="-305.64749"
y="194.14493" /></text><g
aria-label=""
id="text2827-6"
style="font-size:132.452px;line-height:0;font-family:PowerlineSymbols;-inkscape-font-specification:'PowerlineSymbols, Normal';text-align:end;text-anchor:end;fill:#4e94e4;fill-opacity:1;stroke:#ffffff;stroke-width:0"><path
d="M 95.096912,209.8167 143.88912,135.46666 95.096912,61.116629 h 32.818568 l 47.92093,74.350031 -47.92093,74.35004 z"
id="path2883-2"
style="fill:#4e94e4;fill-opacity:1;stroke:#ffffff;stroke-width:0" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 6 KiB

24
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1778716662, "lastModified": 1767609335,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "rev": "250481aafeb741edfe23d29195671c19b36b6dca",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -42,11 +42,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1774860670, "lastModified": 1774604963,
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=", "narHash": "sha256-MtAW1FIdirSlUAAO7s1u9auv5y3I6t3uJ+GeEbqiqxI=",
"owner": "neobrain", "owner": "neobrain",
"repo": "nix2flatpak", "repo": "nix2flatpak",
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba", "rev": "3e04657fbcb49956ac301410b071a7f0b2ad5988",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -73,11 +73,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1777168982, "lastModified": 1765674936,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -88,11 +88,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1778869304, "lastModified": 1767640445,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
"type": "github" "type": "github"
}, },
"original": { "original": {

2
gomuks

@ -1 +1 @@
Subproject commit da3b823e1435afd6f2a1ea6c354d9a35c489b624 Subproject commit daa0ba028e7d89ba9fc7580fc8099348e6145cb3

View file

@ -3,7 +3,6 @@ import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart"; import "package:code_assets/code_assets.dart";
Future<void> main(List<String> args) => build(args, (input, output) async { Future<void> main(List<String> args) => build(args, (input, output) async {
if (!input.config.buildCodeAssets) return;
final codeConfig = input.config.code; final codeConfig = input.config.code;
final targetOS = codeConfig.targetOS; final targetOS = codeConfig.targetOS;
final targetArch = codeConfig.targetArchitecture; final targetArch = codeConfig.targetArchitecture;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -4,10 +4,10 @@ import "package:nexus/models/account_data.dart";
class AccountDataController extends Notifier<IMap<String, AccountData>> { class AccountDataController extends Notifier<IMap<String, AccountData>> {
@override @override
IMap<String, AccountData> build() => .new(); IMap<String, AccountData> build() => const IMap.empty();
void update(IMap<String, AccountData> newData) => void update(IMap<String, AccountData> newData) =>
state = .new({...state.unlock, ...newData.unlock}); state = IMap({...state.unlock, ...newData.unlock});
static final provider = static final provider =
NotifierProvider<AccountDataController, IMap<String, AccountData>>( NotifierProvider<AccountDataController, IMap<String, AccountData>>(

View file

@ -1,30 +1,47 @@
import "dart:async"; 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:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/user_controller.dart"; import "package:nexus/controllers/user_controller.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/membership.dart";
import "package:nexus/models/membership_status.dart";
class AuthorController extends AsyncNotifier<MembershipContent> { class AuthorController extends AsyncNotifier<Membership> {
final Event event; final Message message;
AuthorController(this.event); AuthorController(this.message);
@override @override
Future<MembershipContent> build() async { Future<Membership> build() async {
final member = await ref.watch( final member = await ref.watch(
UserController.provider( UserController.provider(message.authorId).future,
.new(roomId: event.roomId, userId: event.sender),
).future,
); );
return .new( final pmp = message.metadata?["pmp"] == null
status: member.status, ? null
avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl, : Membership.fromContent(
displayName: event.pmp?.displayName ?? member.displayName, 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 = static final provider =
AsyncNotifierProvider.family<AuthorController, MembershipContent, Event>( AsyncNotifierProvider.family<AuthorController, Membership, Message>(
AuthorController.new, AuthorController.new,
); );
} }

View file

@ -1,19 +1,22 @@
import "dart:developer";
import "dart:ffi"; import "dart:ffi";
import "dart:io"; import "dart:io";
import "dart:isolate"; import "dart:isolate";
import "dart:math"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/init_complete_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/rooms_controller.dart";
import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart";
import "package:nexus/controllers/sync_status_controller.dart"; import "package:nexus/controllers/sync_status_controller.dart";
import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate.dart";
import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_event_request.dart";
@ -30,6 +33,7 @@ import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/requests/set_membership_request.dart"; import "package:nexus/models/requests/set_membership_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/models/sync_status.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart"; import "package:path_provider/path_provider.dart";
@ -64,27 +68,26 @@ class ClientController extends AsyncNotifier<int> {
case "client_state": case "client_state":
ref ref
.watch(ClientStateController.provider.notifier) .watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent)); .set(ClientState.fromJson(decodedMuksEvent));
break; break;
case "sync_status": case "sync_status":
ref ref
.watch(SyncStatusController.provider.notifier) .watch(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent)); .set(SyncStatus.fromJson(decodedMuksEvent));
break; break;
case "init_complete": case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete(); ref.watch(InitCompleteController.provider.notifier).complete();
break; break;
case "send_complete": case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]); final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
.new(),
);
if (event.type == "m.room.message") {
ref
.watch(
NewEventsController.provider(event.roomId).notifier,
)
.add(IList([event]));
}
break; break;
case "sync_complete": case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent); final syncData = SyncData.fromJson(decodedMuksEvent);
@ -124,12 +127,9 @@ class ClientController extends AsyncNotifier<int> {
} }
debugPrint("Finished handling $muksEventType..."); debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) { } catch (error, stackTrace) {
if (kDebugMode) { debugger();
debugPrintStack(stackTrace: stackTrace, label: error.toString()); showError(error, stackTrace);
rethrow; debugPrintStack(stackTrace: stackTrace, label: error.toString());
} else {
showError(error, stackTrace);
}
} }
}); });
@ -206,11 +206,9 @@ class ClientController extends AsyncNotifier<int> {
(await _sendCommand("get_room_state", request.toJson())) as List?; (await _sendCommand("get_room_state", request.toJson())) as List?;
final response = await getState(request); final response = await getState(request);
return .new( return (response ?? await getState(request.copyWith(refetch: true)) ?? [])
(response ?? await getState(request.copyWith(refetch: true)) ?? []).map( .map((event) => Event.fromJson(event))
(event) => .fromJson(event), .toIList();
),
);
} }
Future<IList<Event>?> getRelatedEvents( Future<IList<Event>?> getRelatedEvents(
@ -218,21 +216,24 @@ class ClientController extends AsyncNotifier<int> {
) async { ) async {
final response = final response =
(await _sendCommand("get_related_events", request.toJson())) as List?; (await _sendCommand("get_related_events", request.toJson())) as List?;
return .new(response?.map((event) => .fromJson(event))); return response?.map((event) => Event.fromJson(event)).toIList();
} }
Future<Event?> getEvent(GetEventRequest request) async { Future<Event?> 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()); final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json); return json == null ? null : Event.fromJson(json);
} }
Future<Paginate> paginate(PaginateRequest request) async => Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson())); Paginate.fromJson(await _sendCommand("paginate", request.toJson()));
Future<Profile> getProfile(String userId) async { Future<Profile> getProfile(String userId) async =>
final json = await _sendCommand("get_profile", {"user_id": userId}); Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
return .fromJsonWithCatch({...json, "id": userId});
}
Future<void> reportEvent(ReportRequest request) => Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson()); _sendCommand("report_event", request.toJson());
@ -241,8 +242,9 @@ class ClientController extends AsyncNotifier<int> {
_sendCommand("set_membership", request.toJson()); _sendCommand("set_membership", request.toJson());
Future<void> markRead(Room room) async { Future<void> markRead(Room room) async {
final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final event = room.events.firstWhereOrNull(
final event = eventRowId == null ? null : room.events[eventRowId]; (event) => event.rowId == room.timeline.last.eventRowId,
);
if (event == null || room.metadata == null) return; if (event == null || room.metadata == null) return;
await _sendCommand("mark_read", { await _sendCommand("mark_read", {
@ -261,12 +263,12 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<Uri?> discoverHomeserver(Uri homeserver) async { Future<String?> discoverHomeserver(Uri homeserver) async {
try { try {
final response = await _sendCommand("discover_homeserver", { final response = await _sendCommand("discover_homeserver", {
"user_id": "@fake-user:${homeserver.host}", "user_id": "@fake-user:${homeserver.host}",
}); });
return Uri.parse(response["m.homeserver"]?["base_url"]); return response["m.homeserver"]?["base_url"];
} catch (error) { } catch (error) {
return null; return null;
} }

View file

@ -5,7 +5,9 @@ class ClientStateController extends Notifier<ClientState?> {
@override @override
Null build() => null; Null build() => null;
void set(ClientState newState) => state = newState; void set(ClientState newState) {
state = newState;
}
static final provider = NotifierProvider<ClientStateController, ClientState?>( static final provider = NotifierProvider<ClientStateController, ClientState?>(
ClientStateController.new, ClientStateController.new,

View file

@ -2,8 +2,11 @@ import "package:cross_cache/cross_cache.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
class CrossCacheController extends Notifier<CrossCache> { class CrossCacheController extends Notifier<CrossCache> {
static const String spaceKey = "space";
static const String roomKey = "room";
@override @override
CrossCache build() => .new(); CrossCache build() => CrossCache();
static final provider = NotifierProvider<CrossCacheController, CrossCache>( static final provider = NotifierProvider<CrossCacheController, CrossCache>(
CrossCacheController.new, CrossCacheController.new,

View file

@ -1,84 +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<String, EmojiCategory>, IMap<String, List<String>>);
class EmojiController extends AsyncNotifier<EmojiTuple> {
@override
Future<EmojiTuple> build() async {
final response = await get(
.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<String, dynamic>>()
.map(Emoji.fromJson)
.toIList();
final categoryMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.update(
entry.category,
(list) => list.add(entry.emoji),
ifAbsent: () => .new([entry.emoji]),
),
);
final keywordMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.add(
entry.emoji,
.new([...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(
.fromEntries(
keywordMap.entries.map(
(e) => .new(e.key, e.value.toList(growable: false)),
),
),
);
return (customCategories, customKeywords);
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
EmojiController.new,
);
}

View file

@ -1,7 +1,5 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/requests/get_event_request.dart";
@ -11,18 +9,8 @@ class EventController extends AsyncNotifier<Event?> {
@override @override
Future<Event?> build() async { Future<Event?> build() async {
final room = ref.watch( final client = ref.watch(ClientController.provider.notifier);
RoomsController.provider.select((value) => value[request.roomId]), return await client.getEvent(request).onError((_, _) => null);
);
final event = room?.events.values.firstWhereOrNull(
(event) => event.eventId == request.eventId,
);
return event ??
await ref
.watch(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null);
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family

View file

@ -12,14 +12,14 @@ class KeyController extends Notifier<String?> {
String? build() => String? build() =>
ref.watch(SharedPrefsController.provider).requireValue.getString(key); ref.watch(SharedPrefsController.provider).requireValue.getString(key);
Future<void> set(String? value) async { Future<void> set(String? id) async {
final prefs = ref.watch(SharedPrefsController.provider).requireValue; final prefs = ref.watch(SharedPrefsController.provider).requireValue;
state = value; state = id;
if (value == null) { if (id == null) {
prefs.remove(key); prefs.remove(key);
} else { } else {
prefs.setString(key, value); prefs.setString(key, id);
} }
} }

View file

@ -1,32 +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/configs/members_by_status_config.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart";
class MembersByStatusController extends AsyncNotifier<ISet<Event>> {
final MembersByStatusConfig config;
MembersByStatusController(this.config);
@override
Future<ISet<Event>> build() => ref.watch(
MembersController.provider(config.roomId).selectAsync(
(members) => members
.where(
(membership) => switch (membership.content) {
MembershipContent(:final status) => config.status == status,
_ => false,
},
)
.toISet(),
),
);
static final provider =
AsyncNotifierProvider.family<
MembersByStatusController,
ISet<Event>,
MembersByStatusConfig
>(MembersByStatusController.new);
}

View file

@ -0,0 +1,25 @@
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<IList<Membership>> {
final MembershipStatus status;
MembersByTypeController(this.status);
@override
Future<IList<Membership>> build() => ref.watch(
MembersController.provider.selectAsync(
(members) =>
members.where((membership) => membership.status == status).toIList(),
),
);
static final provider =
AsyncNotifierProvider.family<
MembersByTypeController,
IList<Membership>,
MembershipStatus
>(MembersByTypeController.new);
}

View file

@ -1,46 +1,52 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/membership.dart";
import "package:nexus/models/requests/get_room_state_request.dart"; import "package:nexus/models/requests/get_room_state_request.dart";
class MembersController extends AsyncNotifier<ISet<Event>> { class MembersController extends AsyncNotifier<IList<Membership>> {
final String roomId;
MembersController(this.roomId);
@override @override
Future<ISet<Event>> build() async { Future<IList<Membership>> build() async {
final room = ref.watch( final data = ref.watch(
RoomsController.provider.select((value) => value[roomId]), SelectedRoomController.provider.select(
(value) => value?.metadata == null
? null
: (value!.metadata!.id, value.metadata!.hasMemberList),
),
); );
if (data == null) return const IList.empty();
if (room == null) return .new(); final state = await ref
.watch(ClientController.provider.notifier)
.getRoomState(
GetRoomStateRequest(
roomId: data.$1,
fetchMembers: data.$2 == false,
includeMembers: true,
),
);
if (!room.hasFetchedMembers) { return state.nonNulls
final fetchedState = await ref .where((state) => state.type == "m.room.member")
.watch(ClientController.provider.notifier) .map(
.getRoomState( (membership) => Membership.fromContent(
GetRoomStateRequest( membership.content,
roomId: roomId, membership.stateKey!,
fetchMembers: !(room.metadata?.hasMemberList ?? false), ref.watch(
includeMembers: true, ClientStateController.provider.select(
), (value) => value?.homeserverUrl,
); ),
) ??
await ref "",
.read(RoomsController.provider.notifier) ),
.addState(roomId, fetchedState, isMembers: true); )
} .toIList();
return room.state[EventType.membership.type]?.values
.map((rowId) => room.events[rowId])
.nonNulls
.toISet() ??
.new();
} }
static final provider = AsyncNotifierProvider.autoDispose static final provider =
.family<MembersController, ISet<Event>, String>(MembersController.new); AsyncNotifierProvider<MembersController, IList<Membership>>(
MembersController.new,
);
} }

View file

@ -1,70 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_by_status_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/configs/members_by_status_config.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
class MembersGroupedController
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
final MembersByStatusConfig config;
MembersGroupedController(this.config);
@override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
);
final createRowId = room?.state[EventType.create.type]?[""];
final createEvent = createRowId == null ? null : room?.events[createRowId];
final createEventContent = switch (createEvent?.content) {
CreateContent content => content,
_ => null,
};
final creators = createEventContent?.additionalCreatorIds.add(
createEvent!.sender,
);
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
final powerLevelsEvent = powerLevelsRowId == null
? null
: room?.events[powerLevelsRowId];
final content = switch (powerLevelsEvent?.content) {
PowerLevelsContent content => content,
_ => PowerLevelsContent(),
};
final members = await ref.watch(
MembersByStatusController.provider(config).future,
);
return members
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
final groupKey = creators?.contains(event.stateKey!) == true
? null
: content.users[event.stateKey!] ?? content.usersDefault;
return result.update(
groupKey,
(value) => value.add(event),
ifAbsent: () => .new({event}),
);
})
.toEntryIList(
compare: (a, b) =>
(b?.key ?? double.infinity).compareTo(a?.key ?? double.infinity),
);
}
static final provider =
AsyncNotifierProvider.family<
MembersGroupedController,
IList<MapEntry<int?, ISet<Event>>>,
MembersByStatusConfig
>(MembersGroupedController.new);
}

View file

@ -0,0 +1,214 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/requests/get_related_events_request.dart";
class MessageController extends AsyncNotifier<Message?> {
final MessageConfig config;
MessageController(this.config);
@override
Future<Message?> 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<String, IList<String>>>(IMap(), (acc, event) {
final key = event.content["m.relates_to"]?["key"];
if (key == null) return acc;
return acc.update(
key,
(list) => list.add(event.authorId),
ifAbsent: () => IList([event.authorId]),
);
})
.map((key, value) => MapEntry(key, value.unlock))
.unlock;
final asText =
Message.text(
metadata: metadata,
id: config.event.eventId,
reactions: reactions,
authorId: event.authorId,
text: content["formatted_body"] ?? content["body"] ?? "",
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
editedAt: editedAt,
)
as TextMessage;
Message toSystemMessage(String content) => Message.system(
metadata: {...metadata, "body": content},
id: config.event.eventId,
reactions: reactions,
authorId: event.authorId,
deliveredAt: config.event.timestamp,
text: content,
);
return switch (type) {
"m.room.encrypted" => asText.copyWith(
text: "Unable to decrypt message.",
metadata: {...metadata, "body": "Unable to decrypt message."},
),
// "org.matrix.msc3381.poll.start" => Message.custom(
// metadata: {
// ...metadata,
// "poll": event.parsedPollEventContent.pollStartContent,
// "responses": event.getPollResponses(timeline),
// },
// id: eventId,
// deliveredAt: originServerTs,
// authorId: senderId,
// ),
("m.sticker" || "m.room.message") => switch (content["msgtype"]) {
null || "m.image" => Message.image(
id: config.event.eventId,
authorId: event.authorId,
reactions: reactions,
source: source,
replyToMessageId: replyId,
metadata: metadata,
text: asText.text,
deliveredAt: config.event.timestamp,
blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"],
),
"m.audio" || "m.file" => Message.file(
name: content["filename"].toString(),
size: content["info"]["size"],
metadata: metadata,
id: config.event.eventId,
reactions: reactions,
authorId: event.authorId,
source: source,
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
),
_ => asText,
},
"m.room.member" =>
content["membership"] == event.unsigned["prev_content"]?["membership"]
? null
: toSystemMessage(
"${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) {
"invite" => "was invited to",
"join" => "joined",
"leave" => event.authorId == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
"ban" => "was banned from",
"knock" => "asked to join",
_ => "did something relating to",
}} the room. ${content["reason"] ?? ""}",
),
"m.room.server_acl" => toSystemMessage(
"${event.authorId} updated the server ban list.",
),
"m.room.redaction" =>
config.alwaysReturn
? asText.copyWith(
metadata: {
...(asText.metadata ?? {}),
"body": "Deleted Message",
},
)
: null,
_ =>
config.alwaysReturn
? asText
: (
// Turn this on for debugging purposes
false
// ignore: dead_code
? Message.unsupported(
metadata: metadata,
reactions: reactions,
id: config.event.eventId,
authorId: event.authorId,
replyToMessageId: replyId,
)
: null),
};
} catch (error) {
return null;
}
}
static final provider = AsyncNotifierProvider.family
.autoDispose<MessageController, Message?, MessageConfig>(
MessageController.new,
);
}

View file

@ -0,0 +1,27 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/configs/messages_config.dart";
class MessagesController extends AsyncNotifier<IList<Message>> {
final MessagesConfig config;
MessagesController(this.config);
@override
Future<IList<Message>> build() async => (await Future.wait(
config.events.map(
(event) => ref.watch(
MessageController.provider(
MessageConfig(event: event, room: config.room),
).future,
),
),
)).nonNulls.toIList();
static final provider = AsyncNotifierProvider.family
.autoDispose<MessagesController, IList<Message>, MessagesConfig>(
MessagesController.new,
);
}

View file

@ -7,8 +7,9 @@ class MultiProviderController extends AsyncNotifier<void> {
final IList<AsyncNotifierProvider> providers; final IList<AsyncNotifierProvider> providers;
@override @override
Future<void> build() => FutureOr<void> build() async => await Future.wait(
.wait(providers.map((provider) => ref.watch(provider.future))); providers.map((provider) => ref.watch(provider.future)),
);
static final provider = static final provider =
AsyncNotifierProvider.family< AsyncNotifierProvider.family<

View file

@ -0,0 +1,18 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
class NewEventsController extends Notifier<IList<Event>> {
final String roomId;
NewEventsController(this.roomId);
@override
IList<Event> build() => const IList.empty();
void add(IList<Event> newEvents) => state = newEvents;
static final provider = NotifierProvider.autoDispose
.family<NewEventsController, IList<Event>, String>(
NewEventsController.new,
);
}

View file

@ -1,9 +1,9 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/models/configs/power_level_config.dart"; import "package:nexus/models/configs/power_level_config.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/requests/membership_action.dart";
import "package:nexus/models/content/power_levels.dart";
class PowerLevelController extends Notifier<bool> { class PowerLevelController extends Notifier<bool> {
final PowerLevelConfig config; final PowerLevelConfig config;
@ -11,61 +11,56 @@ class PowerLevelController extends Notifier<bool> {
@override @override
bool build() { bool build() {
if (config case EventPowerLevelConfig(:final eventType)) { final room = ref.watch(SelectedRoomController.provider);
assert( final event = room?.events.firstWhereOrNull(
eventType != .redaction, (event) => event.rowId == room.state["m.room.power_levels"]?[""],
"Checking power level for a redaction should use [PowerLevelConfig.redaction].",
);
}
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
); );
final user = ref.watch(ClientStateController.provider)?.userId;
if (event == null || user == null) return false;
final eventRowId = room?.state[EventType.powerLevels.type]?[""]; final users = (event.content["users"] as Map<String, dynamic>? ?? {});
final events = (event.content["events"] as Map<String, dynamic>? ?? {});
final event = eventRowId == null ? null : room?.events[eventRowId]; int powerLevelOf(String userId) => users.containsKey(userId)
final content = event?.content is PowerLevelsContent ? (users[userId] as int)
? event!.content : (event.content["users_default"] as int? ?? 0);
: PowerLevelsContent();
final user = ref.watch(
ClientStateController.provider.select((value) => value?.userId),
);
if (user == null || content is! PowerLevelsContent) return false;
int powerLevelOf(String userId) =>
content.users[userId] ?? content.usersDefault;
final userLevel = powerLevelOf(user); final userLevel = powerLevelOf(user);
final targetLevel = config.targetUser != null
? powerLevelOf(config.targetUser!)
: null;
return switch (config) { if (config.action != null) {
EventPowerLevelConfig(:final eventType) => return switch (config.action!) {
userLevel >= (content.events[eventType.type] ?? content.eventsDefault), MembershipAction.invite =>
userLevel >= (event.content["invite"] as int? ?? 0),
MembershipActionPowerLevelConfig(:final action, :final targetUser) => MembershipAction.kick =>
switch (action) { targetLevel != null &&
.invite => userLevel >= content.invite, userLevel >= (event.content["kick"] as int? ?? 50) &&
userLevel > targetLevel,
.kick => MembershipAction.ban =>
userLevel >= content.kick && userLevel > powerLevelOf(targetUser), targetLevel != null &&
userLevel >= (event.content["ban"] as int? ?? 50) &&
userLevel > targetLevel,
.ban => MembershipAction.unban =>
userLevel >= content.ban && userLevel > powerLevelOf(targetUser), userLevel >= (event.content["ban"] as int? ?? 50),
};
}
.unban => userLevel >= content.ban, if (config.eventType == "m.room.redaction") {
}, return userLevel >= (event.content["redact"] as int? ?? 50);
}
StatePowerLevelConfig(:final eventType) => final requiredLevel = events.containsKey(config.eventType)
userLevel >= (content.events[eventType.type] ?? content.stateDefault), ? (events[config.eventType] as int)
: (config.isStateEvent
? (event.content["state_default"] as int? ?? 50)
: (event.content["events_default"] as int? ?? 0));
RedactionPowerLevelConfig(:final targetUser) => return userLevel >= requiredLevel;
userLevel >=
(targetUser == user
? (content.events[EventType.redaction.type] ??
content.eventsDefault)
: content.redact),
};
} }
static final provider = NotifierProvider.autoDispose static final provider = NotifierProvider.autoDispose

View file

@ -12,6 +12,6 @@ class ProfileController extends AsyncNotifier<Profile> {
return client.getProfile(userId); return client.getProfile(userId);
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.autoDispose
.autoDispose<ProfileController, Profile, String>(ProfileController.new); .family<ProfileController, Profile, String>(ProfileController.new);
} }

View file

@ -1,55 +0,0 @@
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/rooms_controller.dart";
import "package:nexus/models/configs/reactions_config.dart";
import "package:nexus/models/content/reaction.dart";
class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> {
final ReactionsConfig config;
ReactionsController(this.config);
@override
Future<IMap<String, IList<String>>> build() async {
final eventInfo = ref.watch(
RoomsController.provider.select((value) {
final event = value[config.roomId]?.events[config.eventRowId];
return event == null ? null : (event.eventId, event.reactions);
}),
);
final reactionEvents = eventInfo?.$2.isNotEmpty == true
? await ref
.watch(ClientController.provider.notifier)
.getRelatedEvents(
.new(
roomId: config.roomId,
eventId: eventInfo!.$1,
relationType: "m.annotation",
),
)
: null;
return reactionEvents
?.where((event) => event.redactedBy == null)
.fold<IMap<String, IList<String>>>(.new(), (acc, event) {
if (event.content case ReactionContent(:final key?)) {
return acc.update(
key,
(list) => list.add(event.sender),
ifAbsent: () => .new([event.sender]),
);
}
return acc;
}) ??
.new();
}
static final provider =
AsyncNotifierProvider.family<
ReactionsController,
IMap<String, IList<String>>,
ReactionsConfig
>(ReactionsController.new);
}

View file

@ -1,88 +1,223 @@
import "dart:async"; import "dart:async";
import "dart:math";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.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:flutter_riverpod/flutter_riverpod.dart";
import "package:fluttertagger/fluttertagger.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/controllers/messages_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/configs/messages_config.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/requests/get_related_events_request.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart"; import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/relation_type.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/requests/send_message_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<IList<Event>?> { class RoomChatController extends AsyncNotifier<InMemoryChatController> {
final String roomId; final String roomId;
RoomChatController(this.roomId); RoomChatController(this.roomId);
@override @override
Future<IList<Event>?> build() async { Future<InMemoryChatController> build() async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final room = ref.watch( var room = ref.read(RoomsController.provider)[roomId];
RoomsController.provider.select((rooms) => rooms[roomId]), if (room == null) return InMemoryChatController();
final state = await client.getRoomState(
GetRoomStateRequest(roomId: roomId),
); );
if (room == null) return null; ref
.read(RoomsController.provider.notifier)
.update(
{
roomId: Room(
events: state,
state: state.fold(
const IMap.empty(),
(previousValue, stateEvent) => previousValue.add(
stateEvent.type,
(previousValue[stateEvent.type] ?? const IMap.empty()).addAll(
IMap({
if (stateEvent.stateKey != null)
stateEvent.stateKey!: stateEvent.rowId,
}),
),
),
),
),
}.toIMap(),
const ISet.empty(),
);
if (!room.hasFetchedState) { room = ref.read(RoomsController.provider)[roomId];
final state = await client.getRoomState(.new(roomId: roomId)); if (room == null) return InMemoryChatController();
await ref.read(RoomsController.provider.notifier).addState(roomId, state); 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);
} }
// While there are under 20 events, try to load more return controller;
// until there's no more or the conditions are met.
if (room.hasMore && room.timeline.length < 20) {
loadOlder();
}
return room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value)
.toIList()
.addAll(room.sticky)
.map((entry) {
final foundEvent = entry == null ? null : room.events[entry];
final editedEvent =
foundEvent == null || foundEvent.lastEditRowId == 0
? null
: room.events[foundEvent.lastEditRowId];
return editedEvent == null
? foundEvent
: foundEvent?.copyWith(content: editedEvent.content);
})
.nonNulls
.toIList();
} }
Future<void> deleteMessage(Event event, {String? reason}) => ref Future<void> insertMessage(Message message) async {
final controller = await future;
final oldMessage = message.metadata?["txnId"] == null
? null
: controller.messages.firstWhereOrNull(
(element) =>
element.metadata?["txnId"] == message.metadata?["txnId"],
);
return oldMessage == null
? controller.insertMessage(message)
: controller.updateMessage(oldMessage, message);
}
Future<void> deleteMessage(Message message, {String? reason}) => ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.redactEvent( .redactEvent(
RedactEventRequest( RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason),
eventId: event.eventId,
roomId: roomId,
reason: reason,
),
); );
Future<bool> loadOlder() async { Future<bool> loadOlder([InMemoryChatController? chatController]) async {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[roomId]))
?.timeline
.keys;
final response = await ref final response = await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.paginate( .paginate(
.new( PaginateRequest(
roomId: roomId, roomId: roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true maxTimelineId: ref
? timelineKeys?.reduce(min) .read(RoomsController.provider)[roomId]
: null, ?.timeline
.firstOrNull
?.timelineRowId,
), ),
); );
@ -91,22 +226,42 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
.update( .update(
IMap({ IMap({
roomId: Room( roomId: Room(
events: IMap.fromIterable( events: response.events.addAll(response.relatedEvents),
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore, hasMore: response.hasMore,
timeline: IMap.fromIterable( timeline: response.events
response.events, .map(
keyMapper: (event) => event.timelineRowId, (event) => TimelineRowTuple(
valueMapper: (event) => event.rowId, timelineRowId: event.timelineRowId,
), eventRowId: event.rowId,
),
)
.toIList(),
), ),
}), }),
.new(), 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; return response.hasMore;
} }
@ -115,7 +270,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
bool shouldMention = true, bool shouldMention = true,
required IList<Tag> tags, required IList<Tag> tags,
required RelationType relationType, required RelationType relationType,
Event? relation, Message? relation,
}) async { }) async {
var taggedMessage = text; var taggedMessage = text;
@ -130,6 +285,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
} }
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final room = ref.read(RoomsController.provider)[roomId];
final event = await client.sendMessage( final event = await client.sendMessage(
SendMessageRequest( SendMessageRequest(
roomId: roomId, roomId: roomId,
@ -138,40 +294,52 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
if (shouldMention == true && if (shouldMention == true &&
relation != null && relation != null &&
relationType == RelationType.reply) relationType == RelationType.reply)
relation.sender, relation.authorId,
].toIList(), ].toIList(),
room: taggedMessage.contains("@room"), room: taggedMessage.contains("@room"),
), ),
text: taggedMessage, text: taggedMessage,
relation: relation == null relation: relation == null
? null ? null
: .new(eventId: relation.eventId, relationType: relationType), : 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<void> scrollToMessage(Message message) async {
final controller = await future;
Future<void> setFlashing(bool flashing) => controller.updateMessage(
message,
message.copyWith(
metadata: {...(message.metadata ?? {}), "flashing": flashing},
), ),
); );
ref await setFlashing(true);
.watch(RoomsController.provider.notifier) Timer(Duration(seconds: 1), () => setFlashing(false));
.update(
.new({ return await controller.scrollToMessage(message.id);
roomId: .new(
events: .new({event.rowId: event}),
sticky: .new({event.rowId}),
),
}),
.new(),
);
} }
Future<void> removeReaction( Future<void> removeReaction(
String reaction, String reaction,
Event event, Message message,
String userId, String userId,
) async { ) async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents( final allReactionEvents = await client.getRelatedEvents(
.new( GetRelatedEventsRequest(
roomId: roomId, roomId: roomId,
eventId: event.eventId, eventId: message.id,
relationType: "m.annotation", relationType: "m.annotation",
), ),
); );
@ -181,30 +349,30 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
.toIList(); .toIList();
final reactionEvent = reactionEvents?.firstWhereOrNull( final reactionEvent = reactionEvents?.firstWhereOrNull(
(event) => switch (event.content) { (event) =>
ReactionContent(:final key) => event.authorId == userId &&
key == reaction && event.sender == userId, event.content["m.relates_to"]?["key"] == reaction,
_ => false,
},
); );
if (reactionEvent != null) { if (reactionEvent != null) {
await ref await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); .redactEvent(
RedactEventRequest(eventId: reactionEvent.eventId, roomId: roomId),
);
} }
} }
Future<void> sendReaction(String reaction, Event event) async { Future<void> sendReaction(String reaction, Message message) async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
await client.sendEvent( await client.sendEvent(
.new( SendEventRequest(
roomId: roomId, roomId: roomId,
type: EventType.reaction.type, type: "m.reaction",
content: { content: {
"m.relates_to": { "m.relates_to": {
"event_id": event.eventId, "event_id": message.id,
"rel_type": "m.annotation", "rel_type": "m.annotation",
"key": reaction, "key": reaction,
}, },
@ -216,7 +384,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>( .autoDispose<RoomChatController, InMemoryChatController, String>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -1,86 +1,98 @@
import "dart:isolate"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomsController extends Notifier<IMap<String, Room>> { class RoomsController extends Notifier<IMap<String, Room>> {
@override @override
IMap<String, Room> build() => .new(); IMap<String, Room> build() => const IMap.empty();
Future<void> addState( void update(
String roomId, IMap<String, Room> rooms,
IList<Event> state, { ISet<String> leftRooms, {
bool isMembers = false, bool addToNewEvents = true,
}) async => update( }) {
.new({ final homeserver =
roomId: Room( ref.watch(
events: .fromEntries(state.map((event) => .new(event.rowId, event))), ClientStateController.provider.select(
hasFetchedState: true, (value) => value?.homeserverUrl,
hasFetchedMembers: isMembers, ),
state: await Isolate.run(() { ) ??
final newState = state.fold<IMap<String, IMap<String, int>>>( "";
.new(),
(previousValue, stateEvent) => previousValue.add(
stateEvent.type,
(previousValue[stateEvent.type] ?? .new()).add(
stateEvent.stateKey!,
stateEvent.rowId,
),
),
);
return newState;
}),
),
}),
.new(),
);
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
final merged = rooms.entries.fold(state, (acc, entry) { final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key; final roomId = entry.key;
final incoming = entry.value; final incoming = entry.value;
final existing = acc[roomId]; final existing = acc[roomId];
final events = existing?.events.updateById(
incoming.events,
(item) => item.eventId,
);
if (addToNewEvents) {
ref
.watch(NewEventsController.provider(roomId).notifier)
.add(
incoming.timeline
.map(
(timelineTuple) => events?.firstWhereOrNull(
(event) => timelineTuple.eventRowId == event.rowId,
),
)
.nonNulls
.toIList(),
);
}
return acc.add( return acc.add(
roomId, roomId,
existing?.copyWith( existing?.copyWith(
hasMore: incoming.hasMore, hasMore: incoming.hasMore,
sticky: metadata:
(incoming.sticky.isEmpty == true incoming.metadata?.copyWith(
? existing.sticky avatar:
: existing.sticky.addAll(incoming.sticky)) incoming.metadata?.avatar?.mxcToHttps(homeserver) ??
.removeWhere( existing.metadata?.avatar,
(rowId) => incoming.timeline.values.contains(rowId), ) ??
), existing.metadata,
metadata: incoming.metadata ?? existing.metadata, events: events!,
events: incoming.events.isEmpty
? existing.events
: existing.events.addAll(incoming.events),
state: incoming.state.entries.fold( state: incoming.state.entries.fold(
existing.state, existing.state,
(previousValue, event) => previousValue.add( (previousValue, event) => previousValue.add(
event.key, event.key,
(previousValue[event.key] ?? .new()).addAll(event.value), (previousValue[event.key] ?? const IMap.empty()).addAll(
event.value,
),
), ),
), ),
reset: false, timeline:
hasFetchedMembers: (incoming.reset
incoming.hasFetchedMembers || existing.hasFetchedMembers, ? incoming.timeline
hasFetchedState: : existing.timeline.updateById(
incoming.hasFetchedState || existing.hasFetchedState, incoming.timeline,
timeline: (incoming.reset (item) => item.timelineRowId,
? incoming.timeline ))
: existing.timeline.addAll(incoming.timeline)), .sortedBy((element) => element.timelineRowId)
.toIList(),
receipts: incoming.receipts.entries.fold( receipts: incoming.receipts.entries.fold(
existing.receipts, existing.receipts,
(receiptAcc, event) => receiptAcc.add( (receiptAcc, event) => receiptAcc.add(
event.key, event.key,
(receiptAcc[event.key] ?? .new()).addAll(event.value), (receiptAcc[event.key] ?? IList<ReadReceipt>()).addAll(
event.value,
),
), ),
), ),
) ?? ) ??
incoming, incoming.copyWith(
metadata: incoming.metadata?.copyWith(
avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver),
),
),
); );
}); });
@ -88,7 +100,6 @@ class RoomsController extends Notifier<IMap<String, Room>> {
merged, merged,
(acc, roomId) => acc.remove(roomId), (acc, roomId) => acc.remove(roomId),
); );
state = prunedList; state = prunedList;
} }

View file

@ -0,0 +1,24 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/selected_space_controller.dart";
import "package:nexus/models/room.dart";
class SelectedRoomController extends Notifier<Room?> {
@override
Room? build() {
final space = ref.watch(SelectedSpaceController.provider);
final selectedRoomId = ref.watch(
KeyController.provider(KeyController.roomKey),
);
return space.children.firstWhereOrNull(
(room) => room.metadata?.id == selectedRoomId,
) ??
space.children.firstOrNull;
}
static final provider = NotifierProvider<SelectedRoomController, Room?>(
SelectedRoomController.new,
);
}

View file

@ -0,0 +1,22 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/models/space.dart";
class SelectedSpaceController extends Notifier<Space> {
@override
Space build() {
final spaces = ref.watch(SpacesController.provider);
final selectedSpaceId = ref.watch(
KeyController.provider(KeyController.spaceKey),
);
return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ??
spaces.first;
}
static final provider = NotifierProvider<SelectedSpaceController, Space>(
SelectedSpaceController.new,
);
}

View file

@ -3,7 +3,7 @@ import "package:shared_preferences/shared_preferences.dart";
class SharedPrefsController extends AsyncNotifier<SharedPreferences> { class SharedPrefsController extends AsyncNotifier<SharedPreferences> {
@override @override
Future<SharedPreferences> build() async => .getInstance(); Future<SharedPreferences> build() => SharedPreferences.getInstance();
static final provider = static final provider =
AsyncNotifierProvider<SharedPrefsController, SharedPreferences>( AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(

View file

@ -4,7 +4,7 @@ import "package:nexus/models/space_edge.dart";
class SpaceEdgesController extends Notifier<IMap<String, IList<SpaceEdge>>> { class SpaceEdgesController extends Notifier<IMap<String, IList<SpaceEdge>>> {
@override @override
IMap<String, IList<SpaceEdge>> build() => .new(); IMap<String, IList<SpaceEdge>> build() => const IMap.empty();
void set(IMap<String, IList<SpaceEdge>> newEdges) => void set(IMap<String, IList<SpaceEdge>> newEdges) =>
state = state.addAll(newEdges); state = state.addAll(newEdges);

View file

@ -6,9 +6,9 @@ import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space.dart"; import "package:nexus/models/space.dart";
import "package:nexus/models/subspace.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/space_edge.dart";
class SpacesController extends Notifier<IList<Space>> { class SpacesController extends Notifier<IList<Space>> {
@override @override
@ -16,130 +16,118 @@ class SpacesController extends Notifier<IList<Space>> {
final rooms = ref.watch(RoomsController.provider); final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider);
final accountData = ref.watch(AccountDataController.provider);
final childrenById = { final childRoomsBySpaceId = IMap.fromEntries(
for (final entry in spaceEdges.entries) topLevelSpaceIds.map((spaceId) {
entry.key: entry.value.map((e) => e.childId).toList(), ISet<String> walk(String currentId) {
}; final children = spaceEdges[currentId] ?? IList<SpaceEdge>();
Set<String> collectDescendants(String startId) { return children.fold<ISet<String>>(const ISet.empty(), (acc, edge) {
final visited = <String>{}; final childId = edge.childId;
final stack = [startId]; final isSpace = spaceEdges.containsKey(childId);
while (stack.isNotEmpty) { return acc
final current = stack.removeLast(); .addAll(!isSpace ? ISet([childId]) : const ISet.empty())
final children = childrenById[current] ?? const []; .addAll(isSpace ? walk(childId) : const ISet.empty());
});
for (final child in children) {
if (visited.add(child)) {
stack.add(child);
}
} }
}
return visited; return MapEntry(
} spaceId,
walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(),
);
}),
);
Space buildSpace(String spaceId) { final allNestedRoomIds = childRoomsBySpaceId.values
final space = rooms[spaceId]; .expand((l) => l)
final directChildrenIds = childrenById[spaceId] ?? const []; .map(
(room) => rooms.entries
final directRooms = <Room>[]; .firstWhere(
final subSpaces = <Subspace>[]; (entry) => entry.value.metadata?.id == room.metadata?.id,
)
for (final childId in directChildrenIds) { .key,
final room = rooms[childId]; )
if (room == null) continue; .toISet();
if (childrenById.containsKey(childId)) {
final descendants = collectDescendants(childId);
subSpaces.add(
.new(
room: room,
children: .new(descendants.map((id) => rooms[id]).nonNulls),
),
);
} else {
directRooms.add(room);
}
}
return .new(
id: spaceId,
room: space,
title: space?.metadata?.name ?? "Unnamed Space",
children: .new(directRooms),
subSpaces: .new(subSpaces),
);
}
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
final usedRoomIds = {
for (final space in spaces) ...[
...space.children.map((r) => r.metadata?.id),
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
],
}.nonNulls.toISet();
final directMessages = IMap(
accountData["m.direct"]?.content ?? {},
).values.expand((e) => e).toISet();
final otherRooms = rooms.entries final otherRooms = rooms.entries
.where( .where(
(e) => (e) =>
!usedRoomIds.contains(e.key) && !allNestedRoomIds.contains(e.key) &&
!topLevelSpaceIds.contains(e.key) && !topLevelSpaceIds.contains(e.key) &&
!childrenById.containsKey(e.key), !spaceEdges.containsKey(e.key),
) )
.map((e) => e.value) .map((e) => e.value);
.toIList();
final accountData = ref.watch(AccountDataController.provider);
final directMessages = IMap(
accountData["m.direct"]?.content ?? {},
).values.expand((element) => element);
final homeRooms = otherRooms final homeRooms = otherRooms
.where((r) => !directMessages.contains(r.metadata?.id)) .where(
(room) =>
directMessages.any(
(directMessage) => directMessage == room.metadata?.id,
) ==
false,
)
.toIList(); .toIList();
final dmRooms = otherRooms final dmRooms = otherRooms
.where((r) => directMessages.contains(r.metadata?.id)) .where(
(room) => directMessages.any(
(directMessage) => directMessage == room.metadata?.id,
),
)
.toIList(); .toIList();
final allSpaces = <Space>[ final topLevelSpacesList = topLevelSpaceIds
.new( .map((id) {
id: "home", final room = rooms[id];
title: "Home", if (room == null) return null;
icon: Icons.home,
children: homeRooms,
subSpaces: .new(),
),
.new(
id: "dms",
title: "Direct Messages",
icon: Icons.people,
children: dmRooms,
subSpaces: .new(),
),
...spaces,
];
return allSpaces final children = childRoomsBySpaceId[id] ?? IList<Room>();
return Space(
id: id,
title: room.metadata?.name ?? "Unnamed Room",
room: room,
children: children,
);
})
.nonNulls
.toIList();
return <Space>[
Space(
id: "home",
title: "Home",
icon: Icons.home,
children: homeRooms,
),
Space(
id: "dms",
title: "Direct Messages",
icon: Icons.people,
children: dmRooms,
),
...topLevelSpacesList,
]
.map( .map(
(space) => space.copyWith( (space) => space.copyWith(
children: .new( children: space.children
space.children .sortedBy(
.sortedBy( (element) =>
(element) => element
element .metadata
.metadata ?.sortingTimestamp
?.sortingTimestamp .millisecondsSinceEpoch ??
.millisecondsSinceEpoch ?? 0,
0, )
) .sortedBy((room) => room.metadata?.unreadMessages ?? 0)
.sortedBy((room) => room.metadata?.unreadMessages ?? 0) .reversed
.reversed, .toIList(),
),
), ),
) )
.toIList(); .toIList();

View file

@ -7,7 +7,7 @@ class SyncStatusController extends Notifier<SyncStatus?> {
Null build() => null; Null build() => null;
void set(SyncStatus newStatus) { void set(SyncStatus newStatus) {
if (newStatus.type == .permanentlyFailed) { if (newStatus.type == SyncStatusType.permanentlyFailed) {
showError(newStatus.error ?? "Syncing failed"); showError(newStatus.error ?? "Syncing failed");
} }
state = newStatus; state = newStatus;

View file

@ -3,7 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
class TopLevelSpacesController extends Notifier<IList<String>> { class TopLevelSpacesController extends Notifier<IList<String>> {
@override @override
IList<String> build() => .new(); IList<String> build() => const IList.empty();
void set(IList<String> newSpaces) => state = newSpaces; void set(IList<String> newSpaces) => state = newSpaces;

View file

@ -1,25 +1,23 @@
import "dart:convert"; import "dart:convert";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart"; import "package:http/http.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/header_controller.dart"; import "package:nexus/controllers/header_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/open_graph_data.dart";
class UrlPreviewController extends AsyncNotifier<OpenGraphData?> { class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
final String link; final String link;
UrlPreviewController(this.link); UrlPreviewController(this.link);
@override @override
Future<OpenGraphData?> build() async { Future<LinkPreviewData?> build() async {
final homeserver = ref.watch( final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl;
ClientStateController.provider.select((value) => value?.homeserverUrl),
);
if (homeserver != null && !link.contains("matrix.to")) { if (homeserver != null && !link.contains("matrix.to")) {
{ {
final response = await get( final response = await get(
.parse(homeserver) Uri.parse(homeserver)
.resolve("/_matrix/client/v1/media/preview_url") .resolve("/_matrix/client/v1/media/preview_url")
.replace(queryParameters: {"url": link}), .replace(queryParameters: {"url": link}),
headers: await ref.watch(HeaderController.provider.future), headers: await ref.watch(HeaderController.provider.future),
@ -27,14 +25,27 @@ class UrlPreviewController extends AsyncNotifier<OpenGraphData?> {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final decodedValue = json.decode(response.body); final decodedValue = json.decode(response.body);
if (decodedValue is! Map<String, dynamic>) return null;
final mxc = decodedValue["og:image"]; final mxc = decodedValue["og:image"];
final image = mxc == null final image = mxc == null
? null ? null
: Uri.tryParse(mxc)?.mxcToHttps(homeserver); : Uri.tryParse(mxc)?.mxcToHttps(homeserver);
return .fromJson(decodedValue).copyWith(imageUrl: image); 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,
),
);
} }
} }
} }
@ -42,10 +53,8 @@ class UrlPreviewController extends AsyncNotifier<OpenGraphData?> {
return null; return null;
} }
static final provider = static final provider = AsyncNotifierProvider.autoDispose
AsyncNotifierProvider.family< .family<UrlPreviewController, LinkPreviewData?, String>(
UrlPreviewController, UrlPreviewController.new,
OpenGraphData?, );
String
>(UrlPreviewController.new);
} }

View file

@ -4,44 +4,37 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/controllers/profile_controller.dart"; import "package:nexus/controllers/profile_controller.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/configs/user_config.dart"; import "package:nexus/models/membership.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart";
class UserController extends AsyncNotifier<MembershipContent> { class UserController extends AsyncNotifier<Membership?> {
final UserConfig config; final String userId;
UserController(this.config); UserController(this.userId);
@override @override
Future<MembershipContent> build() async { Future<Membership?> build() async {
final member = config.roomId == null final member = await ref.watch(
? null MembersController.provider.selectAsync(
: await ref.watch( (value) =>
MembersController.provider(config.roomId!).selectAsync( value.firstWhereOrNull((membership) => membership.userId == userId),
(value) => value.firstWhereOrNull( ),
(membership) => membership.stateKey == config.userId,
),
),
);
if (member?.content case final MembershipContent content) {
return content;
}
final profile = await ref.watch(
ProfileController.provider(config.userId).future,
); );
return .new( if (member != null) return member;
status: .leave,
avatarUrl: profile.avatarUrl, final profile = await ref.watch(ProfileController.provider(userId).future);
displayName: profile.displayName ?? config.userId.localpart, return Membership(
status: MembershipStatus.leave,
avatarUrl: profile.avatarUrl == null
? null
: Uri.tryParse(profile.avatarUrl!),
displayName: profile.displayName ?? userId.localpart,
userId: userId,
); );
} }
static final provider = static final provider =
AsyncNotifierProvider.family< AsyncNotifierProvider.family<UserController, Membership?, String>(
UserController, UserController.new,
MembershipContent, );
UserConfig
>(UserController.new);
} }

View file

@ -2,9 +2,6 @@ import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class ViaController extends Notifier<String> { class ViaController extends Notifier<String> {
@ -24,29 +21,23 @@ class ViaController extends Notifier<String> {
addUserId(ref.watch(ClientStateController.provider)?.userId); addUserId(ref.watch(ClientStateController.provider)?.userId);
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; final powerLevels = room.events.firstWhereOrNull(
final powerLevels = powerLevelsEventId == null (event) => event.rowId == room.state["m.room.power_levels"]?[""],
? null );
: room.events[powerLevelsEventId];
if (powerLevels?.content case PowerLevelsContent(:final users)) { for (final userId in IMap(powerLevels?.content["users"]).keys) {
for (final userId in users.keys) { addUserId(userId);
addUserId(userId); if (servers.length >= 5) break;
if (servers.length >= 5) break;
}
} }
final members = room.state[EventType.membership.type]?.values.toIList(); final members = room.state["m.room.member"]?.values.toIList();
for (var i = 0; servers.length < 5; i++) { for (var i = 0; servers.length < 5; i++) {
final membershipEventId = members?.getOrNull(i); final member = room.events.firstWhereOrNull(
final member = membershipEventId == null (event) => event.rowId == members?.getOrNull(i),
? null );
: room.events[membershipEventId];
if (member?.content case MembershipContent(:final status)) { if (member?.content["membership"] == "join") {
if (status == .join) { addUserId(member?.stateKey);
addUserId(member?.stateKey);
}
} }
if (members?.getOrNull(i) == null) break; if (members?.getOrNull(i) == null) break;

View file

@ -1,3 +1,3 @@
extension GetLocalpart on String { extension GetLocalpart on String {
String get localpart => length > 1 ? substring(1).split(":").first : "?"; String get localpart => substring(1).split(":").first;
} }

View file

@ -7,8 +7,8 @@ import "package:nexus/src/third_party/gomuks.g.dart";
extension GomuksOwnedBufferToX on GomuksOwnedBuffer { extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
Uint8List toBytes() { Uint8List toBytes() {
try { try {
if (base == nullptr || length <= 0) return .new(0); if (base == nullptr || length <= 0) return Uint8List(0);
return .fromList(base.asTypedList(length)); return Uint8List.fromList(base.asTypedList(length));
} finally { } finally {
calloc.free(base); calloc.free(base);
} }

View file

@ -1,4 +1,5 @@
extension MxcToHttps on Uri { extension MxcToHttps on Uri {
Uri mxcToHttps(String homeserver) => Uri mxcToHttps(String homeserver) => Uri.parse(
.parse(homeserver).resolve("_matrix/client/v1/media/download/$host$path"); homeserver,
).resolve("_matrix/client/v1/media/download/$host$path");
} }

View file

@ -1,16 +1,17 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
extension SchemeToTheme on ColorScheme { extension SchemeToTheme on ColorScheme {
ThemeData get theme => .from(colorScheme: this).copyWith( ThemeData get theme => ThemeData.from(colorScheme: this).copyWith(
cardTheme: .new(color: primaryContainer), cardTheme: CardThemeData(color: primaryContainer),
popupMenuTheme: .new(
shape: RoundedRectangleBorder(borderRadius: .circular(16)),
color: surfaceContainerHigh,
),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
titleSpacing: 0, titleSpacing: 0,
backgroundColor: surfaceContainerLow, backgroundColor: surfaceContainerLow,
), ),
menuTheme: MenuThemeData(
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(primaryContainer),
),
),
textTheme: ThemeData( textTheme: ThemeData(
fontFamilyFallback: ["sans", "emoji"], fontFamilyFallback: ["sans", "emoji"],
brightness: brightness, brightness: brightness,

View file

@ -9,13 +9,14 @@ extension ShowContextMenu on BuildContext {
showMenu( showMenu(
context: this, context: this,
constraints: .loose(Size.infinite), constraints: BoxConstraints.loose(Size.infinite),
position: .fromLTRB( position: RelativeRect.fromLTRB(
globalPosition.dx, globalPosition.dx,
globalPosition.dy, globalPosition.dy,
overlay.size.width - globalPosition.dx, overlay.size.width - globalPosition.dx,
overlay.size.height - globalPosition.dy, overlay.size.height - globalPosition.dy,
), ),
color: Theme.of(this).colorScheme.surfaceContainerHighest,
items: children, items: children,
); );
} }

Some files were not shown because too many files have changed in this diff Show more