Compare commits

..

1 commit

Author SHA1 Message Date
29d0edda30 initial macos 2026-03-20 11:00:06 -06:00
263 changed files with 5442 additions and 7996 deletions

View file

@ -1,39 +0,0 @@
name: "Build APK"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --release"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk

View file

@ -1,37 +0,0 @@
name: "Build Flatpaks"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-flatpak:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Lix GHA Installer Action
uses: samueldr/lix-gha-installer-action@v2026-02-22
with:
extra_nix_config: experimental-features = nix-command flakes flake-self-attrs
- name: Build app
run: nix build .#flatpak
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: flatpak-${{ matrix.arch }}
path: result/nexus.federated.Nexus.flatpak

View file

@ -1,71 +1,46 @@
name: "Build EXE"
name: "Build Windows Version"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-exe:
runs-on: windows-latest
build-windows:
runs-on: "windows-latest"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: "Checkout repository"
uses: "actions/checkout@v4"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.41.9
- name: "Set up Flutter"
uses: "subosito/flutter-action@v2"
- name: Set up Go
uses: actions/setup-go@v6
- name: "Set up Rust"
uses: "dtolnay/rust-toolchain@stable"
with:
go-version-file: gomuks/go.mod
targets: "x86_64-pc-windows-msvc"
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
mingw-w64-x86_64-gcc
- name: "Install Flutter dependencies"
run: flutter pub get
- name: Go build
- name: "Run build_runner & build Windows EXE"
run: |
cd gomuks/pkg/ffi
go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared
- name: Build with Flutter
run: |
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter pub run build_runner build --delete-conflicting-outputs
flutter build windows --release
- name: 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
uses: actions/upload-artifact@v6
- name: "Upload exe zip"
uses: "actions/upload-artifact@v4"
with:
name: windows-portable
path: build/windows/x64/runner/Release/
name: "windows-portable"
path: "build/windows/x64/runner/Release/"
- name: Install Inno Setup
- name: "Install Inno Setup"
run: choco install innosetup -y
- name: Build Inno Setup installer
- name: "Build Inno Setup installer"
run: iscc windows/installer.iss
- name: Upload installer artifact
uses: actions/upload-artifact@v6
- name: "Upload installer artifact"
uses: "actions/upload-artifact@v4"
with:
name: windows-installer
path: windows/dist/Nexus-Setup.exe
name: "windows-installer"
path: "windows/dist/Nexus-Setup.exe"

4
.gitignore vendored
View file

@ -36,9 +36,7 @@ key.properties
# Generated Files
*.g.dart
*.freezed.dart
src/
# Devel Password
password.txt
# Nix
/result

4
.gitmodules vendored
View file

@ -1,4 +0,0 @@
[submodule "gomuks"]
path = gomuks
url = https://github.com/gomuks/gomuks
branch = main

View file

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "nixpkgs000000000000000000000000000000000"
revision: "67323de285b00232883f53b84095eb72be97d35c"
channel: "stable"
project_type: app
@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
- platform: windows
create_revision: nixpkgs000000000000000000000000000000000
base_revision: nixpkgs000000000000000000000000000000000
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
- platform: macos
create_revision: 67323de285b00232883f53b84095eb72be97d35c
base_revision: 67323de285b00232883f53b84095eb72be97d35c
# User provided section

View file

@ -2,14 +2,8 @@
"cSpell.words": [
"Appbar",
"Displayname",
"fluttertagger",
"Gomuks",
"Homeserver",
"Linkified",
"localpart",
"msgtype",
"muks",
"prefs",
"unban"
"vodozemac"
]
}

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/).

137
README.md
View file

@ -1,11 +1,11 @@
# Nexus Client
> [!WARNING]
> Nexus Client is still in development, and doesn't support everything needed for daily use.
> Nexus Client is still heavily in development, and is not ready for use!
## Description
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
A simple and user-friendly Matrix client made with Flutter and the Matrix Dart SDK.
## Screenshots
@ -15,11 +15,15 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
## Progress
- [ ] New logo
- [ ] Make context menus appear as bottom sheets on mobile
- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2
- [ ] Allow using remote gomuks over websocket
- [ ] Platform Support
- [x] Linux
- [x] Windows
- [x] Android
- [ ] MacOS
- [ ] Android
- [ ] iOS
- [ ] Web (may not be possible)
- [x] Login
@ -33,13 +37,13 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] Searching
- [ ] Creating (Rooms, Spaces, and DMs)
- [x] Joining
- [x] Parse vias
- [ ] Parse vias
- [x] Using a text/uri/link
- [x] Plain text
- [x] `matrix:` Uri
- [x] Matrix.to link
- [ ] From space
- [ ] From directory
- [ ] Exploring
- [x] Leaving
- [x] Subspaces
- [x] Messages
@ -50,7 +54,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] HTML/Markdown
- [x] Replies
- [x] Choose ping on/off
- [x] Per message profiles
- [ ] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Mentions
@ -59,11 +62,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
- [ ] Custom emojis/stickers
- [ ] GIFs using Gomuks' GIF proxies
- [x] Receiving
- [x] Recieving
- [x] Plain text
- [x] Per message profiles
- [x] HTML
- [x] URL Previews
- [x] Replies
- [x] Viewing
- [ ] Jump to original message
@ -76,58 +77,43 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Blurhashing
- [ ] Downloading attachments
- [x] Opening attachments in their own view
- [ ] Polls
- [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1
- [x] Mentions
- [x] Users
- [x] Clickable
- [x] Rooms
- [ ] Clickable
- [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest)
- [x] Matrix URIs
- [x] Matrix.to links
- [x] Events
- [ ] Render more nicely
- [ ] Clickable
- [ ] Do some fancy fetching to get nice names
- [ ] Make clickable
- [x] Custom emojis/stickers
- [x] History loading
- [x] Backwards
- [ ] Forwards
- [x] Editing
- [x] Deleting
- [x] Reactions
- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl
- [ ] Pins
- [ ] Displaying
- [ ] Creating
- [ ] Threads
- [x] Profile popouts
- [x] Working actions
- [x] Copy link to:
- [x] Room
- [x] Space
- [x] Message
- [ ] Profile popouts
- [ ] Copy link to [room, space]
- [ ] Reporting
- [x] Events
- [ ] Rooms
- [x] Member list
- [x] Sort by power level
- [ ] Colors based off of power level
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [ ] Notifications using UnifiedPush
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites
- [ ] Settings ([#37](https://git.federated.nexus/Nexus/nexus/issues/37))
- [ ] Matrix: URIs vs Matrix.to links
- [ ] Settings
- [ ] Light/Dark mode
- [ ] Remote Gomuks instance
- [ ] SSD or CSD
- [ ] Align your message bubbles to left or right
- [ ] Show media by default
- [ ] Dynamic Theming
- [ ] Personas
- [ ] Setting per-message profiles for users (MSC4461)
- [ ] Explain how to send messages using a certain PMP
- [ ] Devices
- [ ] Viewing devices
- [ ] Verifying devices
- [ ] URL preview: Server / Sending Client (Beeper spec) / None
- [ ] URL preview: Server / Client / None
- [ ] Account changes
- [ ] Display name
- [ ] Profile picture
@ -137,64 +123,25 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] About
- [x] Log Out
## Try it out
## Build Instructions
If you want to try out Nexus, grab one of the following artifacts from CI:
First, clone and open the repo:
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
- Windows
- [Portable Build](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-portable.zip)
- [Installer](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
- Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
Or, try the Nix package: `nix run git+https://git.federated.nexus/Nexus/nexus`
## Build it yourself
```sh
git clone https://git.federated.nexus/Henry-Hiles/nexus
cd nexus
```
### Prerequisites
#### Linux
- With Nix: Either use direnv and `direnv allow`, or `nix flake develop`
- Without Nix: Install Flutter, Go, Git, Libclang, and Glibc. Do not use any Snap packages, they cause various compilation issues.
- With Nix: Either use direnv, or `nix flake develop`
- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc.
#### Windows
#### Windows / MacOS
You will need:
- Flutter
- Android SDK + NDK
- Git
- Go
- Visual Studio 2022 (Desktop development with C++)
- [MSYS2/MinGW-w64 GCC](https://www.msys2.org/) (for CGO)
- [LLVM/Clang + libclang](https://clang.llvm.org/get_started.html) (for `ffigen`)
On Windows, make sure these are available in your shell `PATH`:
- `C:\msys64\ucrt64\bin` (or your MinGW bin path containing `x86_64-w64-mingw32-gcc.exe`)
- `C:\Program Files\LLVM\bin` (contains `clang.exe` and `libclang.dll`)
For `dart scripts/generate.dart`, you may also need:
```powershell
$env:CPATH = "C:\msys64\ucrt64\include"
```
#### MacOS
Similar prerequisites apply (Flutter, Git, Go, C toolchain, LLVM/libclang), but exact setup has not been fully documented yet.
### Clone repo
First, clone and open the repo:
```sh
git clone --recurse-submodules https://git.federated.nexus/Nexus/nexus
cd nexus
```
I don't really know. You will need Flutter, Git, Olm, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM.
### Set up Flutter
@ -204,23 +151,22 @@ Get dependencies:
flutter pub get
```
Generate Gomuks bindings:
Get dependencies:
```sh
dart scripts/generate.dart
flutter pub get
```
> [!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"
> ```
Clone Gomuks and generate bindings:
```sh
scripts/generate.sh
```
Build generated files, and watch for new changes:
```sh
flutter pub run build_runner watch
flutter pub run build_runner watch --delete-conflicting-outputs
```
Run the app:
@ -229,13 +175,6 @@ Run the app:
flutter run
```
Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md).
## Community
Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.
# Credits
Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus!
Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus!

View file

@ -39,11 +39,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
// do we want to update.. eventually?
jvmTarget = "17"
}
defaultConfig {
applicationId = "nexus.federated.Nexus"
minSdk = 29
@ -55,8 +50,7 @@ android {
signingConfigs {
release {
keyAlias "key"
def storePath = keystoreProperties['path'] ?: System.getenv("KEYSTORE_PATH")
storeFile storePath ? file(storePath) : null
storeFile keystoreProperties['path'] ? file(keystoreProperties['path']) : file(System.getenv("KEYSTORE_PATH"))
keyPassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD")
storePassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD")
}

View file

@ -10,7 +10,7 @@
android:label="Nexus"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/nexus_round"
android:allowBackup="false"
android:fullBackupContent="false">
<activity

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.7 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.7 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 12 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 18 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -1,14 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@drawable/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
</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

View file

@ -1,257 +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="background.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
inkscape:export-filename="background.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="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="0.69191503"
inkscape:cx="-71.540576"
inkscape:cy="281.10388"
inkscape:window-width="2544"
inkscape:window-height="1363"
inkscape:window-x="35"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="svg11" />
<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>
<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>
<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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

View file

@ -1,19 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg11"
sodipodi:docname="foreground.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="nexus.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="namedview11"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
@ -21,137 +22,105 @@
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="0.87695313"
inkscape:cx="152.23163"
inkscape:cy="347.22494"
inkscape:window-width="2544"
inkscape:window-height="1363"
inkscape:document-units="mm"
inkscape:zoom="1.0847363"
inkscape:cx="58.07863"
inkscape:cy="214.3378"
inkscape:window-width="1896"
inkscape:window-height="987"
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" />
<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="path8"
style="fill:url(#paint0_radial_4033_8);stroke-width:4" />
<g
mask="url(#mask0_4033_8)"
id="g9"
transform="scale(4)">
<rect
x="52"
y="46"
width="17"
height="4"
fill="#2779dd"
id="rect9" />
</g>
<defs
id="defs11">
<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>
</defs>
</svg>
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:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 19.377906,68.106953 80.937684,32.43771"
id="path10" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="m 19.044488,32.469148 61.61782,35.569625"
id="path9" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 50,85.574911 V 14.425087"
id="path8" /><circle
style="fill:none;stroke:#ffffff;stroke-width:7.11498;stroke-linecap:round;stroke-linejoin:round"
id="path1"
cx="50"
cy="50"
r="35.574913" /><circle
style="fill:#09bd05;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="path2"
cx="50"
cy="84.604881"
r="8.2508707" /><circle
style="fill:#fe1e24;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle2"
cx="50"
cy="15.395123"
r="8.2508707" /><circle
style="fill:#fe941d;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle3"
cx="-68.30127"
cy="52.906147"
r="8.2508707"
transform="rotate(-120)" /><circle
style="fill:#001996;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle4"
cx="-68.30127"
cy="-16.30361"
r="8.2508707"
transform="rotate(-120)" /><circle
style="fill:#ffff04;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle5"
cx="-18.301271"
cy="102.90615"
r="8.2508707"
transform="rotate(-60)" /><circle
style="fill:#770287;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle6"
cx="-18.301271"
cy="33.696392"
r="8.2508707"
transform="rotate(-60)" /><circle
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:6.75;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path7"
cx="50"
cy="50"
r="9.7918472" /><g
inkscape:label="Layer 1"
id="layer1-3"
transform="matrix(0.08246781,0,0,0.08246781,38.828362,38.828362)"
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>

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"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
fill="none"
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg35"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
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: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="namedview35"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
@ -24,311 +23,128 @@
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="1.321682"
inkscape:cx="69.608271"
inkscape:cy="120.67956"
inkscape:window-width="2544"
inkscape:window-height="1363"
inkscape:document-units="mm"
inkscape:zoom="1.0847363"
inkscape:cx="57.61769"
inkscape:cy="214.33781"
inkscape:window-width="1896"
inkscape:window-height="963"
inkscape:window-x="35"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="svg35" />
<mask
id="mask0_4023_558"
maskUnits="userSpaceOnUse"
x="12"
y="100"
width="88"
height="16">
<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
inkscape:current-layer="layer1" /><defs
id="defs1"><linearGradient
id="linearGradient10"
inkscape:collect="always"><stop
style="stop-color:#c7a312;stop-opacity:1;"
offset="0"
id="stop10" /><stop
style="stop-color:#26a0b3;stop-opacity:1;"
offset="1"
stop-color="#26A269"
id="stop16" />
</linearGradient>
<linearGradient
id="paint1_linear_4023_558"
x1="12"
y1="108"
x2="17"
y2="108"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#1A5FB4"
id="stop17" />
<stop
offset="1"
stop-color="#35E0F6"
id="stop18" />
</linearGradient>
<linearGradient
id="paint2_linear_4023_558"
x1="100"
y1="108"
x2="78"
y2="108"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#A51D2D"
id="stop19" />
<stop
offset="0.195858"
stop-color="#E5673C"
id="stop20" />
<stop
offset="0.5983"
stop-color="#A51D2D"
id="stop21" />
</linearGradient>
<linearGradient
id="paint3_linear_4023_558"
x1="88"
y1="111.329"
x2="24"
y2="111.329"
gradientUnits="userSpaceOnUse">
<stop
offset="0.102371"
stop-color="white"
stop-opacity="0"
id="stop22" />
<stop
offset="0.253808"
stop-color="white"
id="stop23" />
<stop
offset="0.747697"
stop-color="white"
id="stop24" />
<stop
offset="0.895556"
stop-color="white"
stop-opacity="0"
id="stop25" />
</linearGradient>
<linearGradient
id="paint4_linear_4023_558"
x1="44"
y1="48.036098"
x2="126"
y2="48.036098"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#DBEBF4"
id="stop26" />
<stop
offset="0.147387"
stop-color="#B1D4E7"
id="stop27" />
<stop
offset="0.186621"
stop-color="#8DC0DC"
id="stop28" />
<stop
offset="0.203755"
stop-color="#49AEE7"
id="stop29" />
<stop
offset="0.276122"
stop-color="#7AB5D7"
id="stop30" />
<stop
offset="0.399628"
stop-color="#B3D6E7"
id="stop31" />
<stop
offset="0.507537"
stop-color="#B3D6E7"
id="stop32" />
<stop
offset="1"
stop-color="#DBEBF4"
id="stop33" />
</linearGradient>
<radialGradient
id="paint5_radial_4023_558"
cx="0"
cy="0"
r="1"
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>
id="stop11" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient10"
id="linearGradient11"
x1="20.031296"
y1="32.697563"
x2="90.709213"
y2="66.3423"
gradientUnits="userSpaceOnUse" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><rect
style="fill:url(#linearGradient11);fill-opacity:1;stroke:none;stroke-width:7.99999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="rect10"
width="100"
height="100"
x="0"
y="0"
ry="28.294127" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 19.377906,68.106953 80.937684,32.43771"
id="path10" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="m 19.044488,32.469148 61.61782,35.569625"
id="path9" /><path
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
d="M 50,85.574911 V 14.425087"
id="path8" /><circle
style="fill:none;stroke:#ffffff;stroke-width:7.11498;stroke-linecap:round;stroke-linejoin:round"
id="path1"
cx="50"
cy="50"
r="35.574913" /><circle
style="fill:#09bd05;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="path2"
cx="50"
cy="84.604881"
r="8.2508707" /><circle
style="fill:#fe1e24;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle2"
cx="50"
cy="15.395123"
r="8.2508707" /><circle
style="fill:#fe941d;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle3"
cx="-68.30127"
cy="52.906147"
r="8.2508707"
transform="rotate(-120)" /><circle
style="fill:#001996;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle4"
cx="-68.30127"
cy="-16.30361"
r="8.2508707"
transform="rotate(-120)" /><circle
style="fill:#ffff04;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle5"
cx="-18.301271"
cy="102.90615"
r="8.2508707"
transform="rotate(-60)" /><circle
style="fill:#770287;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;fill-opacity:1;stroke-dasharray:none"
id="circle6"
cx="-18.301271"
cy="33.696392"
r="8.2508707"
transform="rotate(-60)" /><circle
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:6.75;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="path7"
cx="50"
cy="50"
r="9.7918472" /><g
inkscape:label="Layer 1"
id="layer1-3"
transform="matrix(0.08246781,0,0,0.08246781,38.828362,38.828362)"
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>

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/reply-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/reply.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

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

93
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1767609335,
"narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "250481aafeb741edfe23d29195671c19b36b6dca",
"type": "github"
},
"original": {
@ -18,54 +18,17 @@
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix2flatpak": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1774860670,
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=",
"owner": "neobrain",
"repo": "nix2flatpak",
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba",
"type": "github"
},
"original": {
"owner": "neobrain",
"repo": "nix2flatpak",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773389992,
"narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=",
"owner": "NixOS",
"lastModified": 1767640445,
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c06b4ae3d6599a672a6210b7021d699c351eebda",
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
"type": "github"
},
"original": {
"owner": "NixOS",
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
@ -73,11 +36,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"lastModified": 1765674936,
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
"type": "github"
},
"original": {
@ -86,42 +49,10 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nix2flatpak": "nix2flatpak",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
"nixpkgs": "nixpkgs"
}
}
},

View file

@ -2,10 +2,8 @@
description = "Nexus Flutter Flake";
inputs = {
self.submodules = true;
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
nix2flatpak.url = "github:neobrain/nix2flatpak";
};
outputs =
@ -40,37 +38,29 @@
};
};
packages =
devShells =
let
default = pkgs.callPackage ./linux/nix/pkg {
src = self;
packages = with pkgs; [
go
git
];
env = {
LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ];
LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}";
CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ];
};
in
{
inherit default;
flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak {
appName = "Nexus";
developer = "QuadRadical";
appId = "nexus.federated.Nexus";
package = default;
runtime = "org.gnome.Platform/49";
permissions = {
share = [ "network" ];
sockets = [
"fallback-x11"
"wayland"
];
devices = [ "dri" ];
};
default = pkgs.mkShell {
inherit env;
packages = packages ++ [
pkgs.flutter
];
};
gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix {
src = self;
};
nix = pkgs.mkShell { inherit packages env; };
};
devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { };
};
};
}

1
gomuks

@ -1 +0,0 @@
Subproject commit 23638a8d2b5ad7ed9f72a0ec39f56cac119c45fb

View file

@ -3,7 +3,9 @@ import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart";
Future<void> main(List<String> args) => build(args, (input, output) async {
if (!input.config.buildCodeAssets) return;
final buildDir = input.packageRoot.resolve("src/");
if (await File(buildDir.resolve("lock").toFilePath()).exists()) return;
final codeConfig = input.config.code;
final targetOS = codeConfig.targetOS;
final targetArch = codeConfig.targetArchitecture;
@ -16,6 +18,15 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
break;
case OS.macOS:
libFileName = "libgomuks.dylib";
final sdkRoot = await _macSdkRoot();
final goArch = targetArch == Architecture.arm64 ? "arm64" : "amd64";
env = {
"GOARCH": goArch,
"CGO_ENABLED": "1",
"CGO_CFLAGS": "-isysroot $sdkRoot",
"CGO_CXXFLAGS": "-isysroot $sdkRoot",
"CGO_LDFLAGS": "-isysroot $sdkRoot",
};
break;
case OS.windows:
libFileName = "libgomuks.dll";
@ -23,58 +34,52 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
case OS.android:
libFileName = "libgomuks.so";
if (targetArch != Architecture.arm64) {
throw UnsupportedError(
"only arm64 for now... got: $targetArch",
);
}
final targetNdkApi = codeConfig.android.targetNdkApi;
final ndkHome =
Platform.environment["ANDROID_NDK_HOME"] ??
Platform.environment["ANDROID_NDK_ROOT"] ??
Platform.environment["NDK_HOME"] ??
await _findNdkFromSdk();
final ndkHome = Platform.environment["ANDROID_NDK_HOME"]
?? Platform.environment["ANDROID_NDK_ROOT"]
?? Platform.environment["NDK_HOME"];
if (ndkHome == null) {
throw Exception(
"Could not find Android NDK. Set ANDROID_NDK_HOME or install via sdkmanager.",
"ANDROID_NDK_HOME, ANDROID_NDK_ROOT, or NDK_HOME must be set for Android builds",
);
}
final hostTag = _ndkHostTag();
final (goArch, ccTriple) = _androidArch(targetArch);
final cc =
"$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang";
final cc = "$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/aarch64-linux-android$targetNdkApi-clang";
env = {"CGO_ENABLED": "1", "GOOS": "android", "GOARCH": goArch, "CC": cc};
env = {
"CGO_ENABLED": "1",
"GOOS": "android",
"GOARCH": "arm64",
"CC": cc,
};
break;
default:
throw UnsupportedError("Unsupported OS: $targetOS");
}
var libFile = input.packageRoot.resolve(libFileName);
final gomuksBuildDir = input.packageRoot.resolve("gomuks/");
final gomuksBuildDir = buildDir.resolve("gomuks/");
final libFile = gomuksBuildDir.resolve("${targetArch.name}/$libFileName");
if (!(await File.fromUri(libFile).exists())) {
final buildDir = input.packageRoot.resolve("build/");
libFile = buildDir.resolve("${targetArch.name}/$libFileName");
print("Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) from source...");
final result = await Process.run("go", [
"build",
"-tags", "goolm",
"-o",
libFile.path,
"-buildmode=c-shared",
], workingDirectory: gomuksBuildDir.resolve("source/pkg/ffi/").toFilePath(),
environment: env.isNotEmpty ? env : null);
// goheif/dav1d supported on Android would need to fix upstream
final tags = [
"sqlite_fts5",
"goolm",
if (targetOS == OS.android) "noheic",
].join(",");
print(
"Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) to ${libFile.path}...",
);
final result = await Process.run(
"go",
["build", "-tags", tags, "-o", libFile.path, "-buildmode=c-shared"],
workingDirectory: gomuksBuildDir.resolve("pkg/ffi/").toFilePath(),
environment: env.isNotEmpty ? env : null,
);
if (result.exitCode != 0) {
throw Exception(
"Failed to build Gomuks shared library\n${result.stderr}",
);
}
if (result.exitCode != 0) {
throw Exception("Failed to build Gomuks shared library\n${result.stderr}");
}
final generatedFile = "src/third_party/gomuks.g.dart";
@ -93,18 +98,12 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
print("Done!");
});
Future<String?> _findNdkFromSdk() async {
// pretty sure this wont be needed with nix, i'll get this removed
final androidHome =
Platform.environment["ANDROID_HOME"] ??
Platform.environment["ANDROID_SDK_ROOT"];
if (androidHome == null) return null;
final ndkDir = Directory("$androidHome/ndk");
if (!await ndkDir.exists()) return null;
final versions = await ndkDir.list().toList();
if (versions.isEmpty) return null;
versions.sort((a, b) => a.path.compareTo(b.path));
return versions.last.path;
Future<String> _macSdkRoot() async {
final result = await Process.run("xcrun", ["--show-sdk-path"]);
if (result.exitCode != 0) {
throw Exception("Failed to find macOS SDK: ${result.stderr}");
}
return (result.stdout as String).trim();
}
String _ndkHostTag() {
@ -117,18 +116,3 @@ String _ndkHostTag() {
}
throw UnsupportedError("Unsupported host platform for Android NDK");
}
(String goArch, String ccTriple) _androidArch(Architecture arch) {
switch (arch) {
case Architecture.arm64:
return ("arm64", "aarch64-linux-android");
case Architecture.arm:
return ("arm", "armv7a-linux-androideabi");
case Architecture.x64:
return ("amd64", "x86_64-linux-android");
case Architecture.ia32:
return ("386", "i686-linux-android");
default:
throw UnsupportedError("Unsupported Android architecture: $arch");
}
}

View file

@ -387,7 +387,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus;
PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@ -519,7 +519,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus;
PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -545,7 +545,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.Nexus;
PRODUCT_BUNDLE_IDENTIFIER = nexus.federated.nexus;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

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>> {
@override
IMap<String, AccountData> build() => .new();
IMap<String, AccountData> build() => const IMap.empty();
void update(IMap<String, AccountData> newData) =>
state = .new({...state.unlock, ...newData.unlock});
state = IMap({...state.unlock, ...newData.unlock});
static final provider =
NotifierProvider<AccountDataController, IMap<String, AccountData>>(

View file

@ -1,30 +0,0 @@
import "dart:async";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user_controller.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart";
class AuthorController extends AsyncNotifier<MembershipContent> {
final Event event;
AuthorController(this.event);
@override
Future<MembershipContent> build() async {
final member = await ref.watch(
UserController.provider(
.new(roomId: event.roomId, userId: event.sender),
).future,
);
return .new(
status: member.status,
avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl,
displayName: event.pmp?.displayName ?? member.displayName,
);
}
static final provider =
AsyncNotifierProvider.family<AuthorController, MembershipContent, Event>(
AuthorController.new,
);
}

View file

@ -1,7 +1,7 @@
import "dart:developer";
import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "dart:math";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart";
@ -13,7 +13,7 @@ import "package:nexus/controllers/space_edges_controller.dart";
import "package:nexus/controllers/sync_status_controller.dart";
import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/paginate.dart";
import "package:nexus/models/requests/get_event_request.dart";
@ -25,27 +25,17 @@ import "package:nexus/models/profile.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/requests/report_request.dart";
import "package:nexus/models/requests/send_event_request.dart";
import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/requests/set_membership_request.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/models/sync_status.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart";
class ClientController extends AsyncNotifier<int> {
@override
Future<int> build() async {
final Pointer<Char> root;
if (Platform.isAndroid) {
final dir = await getApplicationSupportDirectory();
root = "${dir.path}/gomuks".toNativeUtf8().cast();
} else {
root = nullptr.cast();
}
final handle = GomuksInit(root);
final handle = await Isolate.run(GomuksInit);
final callable =
NativeCallable<
@ -64,27 +54,15 @@ class ClientController extends AsyncNotifier<int> {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
.set(ClientState.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
.set(SyncStatus.fromJson(decodedMuksEvent));
break;
case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete();
break;
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
.new(),
);
break;
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
@ -124,12 +102,8 @@ class ClientController extends AsyncNotifier<int> {
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
debugger();
debugPrintStack(stackTrace: stackTrace, label: error.toString());
}
});
@ -166,18 +140,15 @@ class ClientController extends AsyncNotifier<int> {
Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson());
Future<Event> sendMessage(SendMessageRequest request) async =>
Event.fromJson(await _sendCommand("send_message", request.toJson()));
Future<void> sendMessage(SendMessageRequest request) =>
_sendCommand("send_message", request.toJson());
Future<Event> sendEvent(SendEventRequest request) async =>
Event.fromJson(await _sendCommand("send_event", request.toJson()));
Future<String?> verify(String recoveryKey) async {
Future<bool> verify(String recoveryKey) async {
try {
await _sendCommand("verify", {"recovery_key": recoveryKey});
return null;
return true;
} catch (error) {
return error.toString();
return false;
}
}
@ -202,15 +173,9 @@ class ClientController extends AsyncNotifier<int> {
// }));
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
Future<List?> getState(GetRoomStateRequest request) async =>
(await _sendCommand("get_room_state", request.toJson())) as List?;
final response = await getState(request);
return .new(
(response ?? await getState(request.copyWith(refetch: true)) ?? []).map(
(event) => .fromJson(event),
),
);
final response =
(await _sendCommand("get_room_state", request.toJson())) as List;
return response.map((event) => Event.fromJson(event)).toIList();
}
Future<IList<Event>?> getRelatedEvents(
@ -218,31 +183,32 @@ class ClientController extends AsyncNotifier<int> {
) async {
final response =
(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 {
final event = request.room.events.firstWhereOrNull(
(event) => event.eventId == request.eventId,
);
if (event != null) return event;
final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json);
return json == null ? null : Event.fromJson(json);
}
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 {
final json = await _sendCommand("get_profile", {"user_id": userId});
return .fromJsonWithCatch({...json, "id": userId});
}
Future<Profile> getProfile(String userId) async =>
Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson());
Future<void> setMembership(SetMembershipRequest request) =>
_sendCommand("set_membership", request.toJson());
Future<void> reportEvent(ReportRequest report) =>
_sendCommand("report_event", report.toJson());
Future<void> markRead(Room room) async {
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId];
final event = room.events.firstWhereOrNull(
(event) => event.rowId == room.timeline.last.eventRowId,
);
if (event == null || room.metadata == null) return;
await _sendCommand("mark_read", {
@ -252,21 +218,21 @@ class ClientController extends AsyncNotifier<int> {
});
}
Future<String?> login(LoginRequest login) async {
Future<bool> login(LoginRequest login) async {
try {
await _sendCommand("login", login.toJson());
return null;
return true;
} catch (error) {
return error.toString();
return false;
}
}
Future<Uri?> discoverHomeserver(Uri homeserver) async {
Future<String?> discoverHomeserver(Uri homeserver) async {
try {
final response = await _sendCommand("discover_homeserver", {
"user_id": "@fake-user:${homeserver.host}",
"user_id": "@fakeuser:${homeserver.host}",
});
return Uri.parse(response["m.homeserver"]?["base_url"]);
return response["m.homeserver"]?["base_url"];
} catch (error) {
return null;
}

View file

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

View file

@ -2,8 +2,11 @@ import "package:cross_cache/cross_cache.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class CrossCacheController extends Notifier<CrossCache> {
static const String spaceKey = "space";
static const String roomKey = "room";
@override
CrossCache build() => .new();
CrossCache build() => CrossCache();
static final provider = NotifierProvider<CrossCacheController, CrossCache>(
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:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_event_request.dart";
@ -11,18 +9,8 @@ class EventController extends AsyncNotifier<Event?> {
@override
Future<Event?> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[request.roomId]),
);
final event = room?.events.values.firstWhereOrNull(
(event) => event.eventId == request.eventId,
);
return event ??
await ref
.watch(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null);
final client = ref.watch(ClientController.provider.notifier);
return await client.getEvent(request).onError((_, _) => null);
}
static final provider = AsyncNotifierProvider.family

View file

@ -12,14 +12,14 @@ class KeyController extends Notifier<String?> {
String? build() =>
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;
state = value;
state = id;
if (value == null) {
if (id == null) {
prefs.remove(key);
} 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

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

View file

@ -1,64 +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/room_creators_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/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 roomCreators = room == null
? null
: ref.watch((RoomCreatorsController.provider(room)));
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 = roomCreators?.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,205 @@
import "package:collection/collection.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/message_config.dart";
class MessageController extends AsyncNotifier<Message?> {
final MessageConfig config;
MessageController(this.config);
@override
Future<Message?> build() async {
try {
if (config.event.relationType == "m.replace" && !config.includeEdits) {
return null;
}
if (!ref.mounted) return null;
final event = config.event.lastEditRowId == null
? config.event
: config.room.events.firstWhereOrNull(
(e) => e.rowId == config.event.lastEditRowId,
) ??
config.event;
if (!ref.mounted) return null;
final members = ref.read(MembersController.provider(config.room));
final author = members.firstWhereOrNull(
(member) => member.stateKey == event.authorId,
);
if (!ref.mounted) return null;
final content = (event.decrypted ?? event.content);
final type = (config.event.decryptedType ?? config.event.type);
final newContent = content["m.new_content"] as Map?;
final 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
? (newContent?["body"] ?? content["body"] ?? "")
: "Deleted Message",
"flashing": false,
"timelineId": event.timelineRowId,
"big": event.localContent?.bigEmoji == true,
"eventType": type,
"avatarUrl": author?.content["avatar_url"],
"editSource":
event.localContent?.editSource ??
newContent?["body"] ??
content["body"],
"displayName": author?.content["displayname"]?.isNotEmpty == true
? author?.content["displayname"]
: event.authorId.substring(1).split(":")[0],
"txnId": config.event.transactionId,
};
if (!ref.mounted) return null;
final editedAt = event.relationType == "m.replace"
? event.timestamp
: null;
if ((event.redactedBy != null && !config.alwaysReturn) ||
(!config.includeEdits &&
(config.event.relationType == "m.replace"))) {
return null;
}
// TODO: Use server-generated preview if enabled
// final match = Uri.tryParse(
// RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "",
// );
final replyId =
config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"];
final asText =
Message.text(
metadata: metadata,
id: config.event.eventId,
authorId: event.authorId,
text:
newContent?["formatted_body"] ??
newContent?["body"] ??
content["formatted_body"] ??
content["body"] ??
"",
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
editedAt: editedAt,
)
as TextMessage;
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,
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,
authorId: event.authorId,
source: source,
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
),
_ => asText,
},
"m.room.member" =>
content["membership"] == event.unsigned["prev_content"]?["membership"]
? null
: Message.system(
metadata: {
...metadata,
"body":
"${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) {
"invite" => "was invited to",
"join" => "joined",
"leave" => "left",
"knock" => "asked to join",
"ban" => "was banned from",
_ => "did something relating to",
}} the room.",
},
id: config.event.eventId,
authorId: event.authorId,
deliveredAt: config.event.timestamp,
text:
"${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) {
"invite" => "was invited to",
"join" => "joined",
"leave" => "left",
"knock" => "asked to join",
"ban" => "was banned from",
_ => "did something relating to",
}} the room.",
),
"m.room.redaction" =>
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,
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/message_config.dart";
import "package:nexus/models/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;
@override
Future<void> build() =>
.wait(providers.map((provider) => ref.watch(provider.future)));
FutureOr<void> build() async => await Future.wait(
providers.map((provider) => ref.watch(provider.future)),
);
static final provider =
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,81 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/room_creators_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/configs/power_level_config.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
class PowerLevelController extends Notifier<bool> {
final PowerLevelConfig config;
PowerLevelController(this.config);
@override
bool build() {
if (config case EventPowerLevelConfig(:final eventType)) {
assert(
eventType != .redaction,
"Checking power level for a redaction should use [PowerLevelConfig.redaction].",
);
}
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
);
final roomCreators = room == null
? null
: ref.watch(RoomCreatorsController.provider(room));
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
final event = eventRowId == null ? null : room?.events[eventRowId];
final content = event?.content is PowerLevelsContent
? event!.content
: PowerLevelsContent();
final user = ref.watch(
ClientStateController.provider.select((value) => value?.userId),
);
if (user == null || content is! PowerLevelsContent) return false;
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
final userLevel = powerLevelOf(user);
return switch (config) {
EventPowerLevelConfig(:final eventType) =>
userLevel >= (content.events[eventType.type] ?? content.eventsDefault),
MembershipActionPowerLevelConfig(:final action, :final targetUser) =>
switch (action) {
.invite => userLevel >= content.invite,
.kick =>
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
.ban =>
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
.unban => userLevel >= content.ban,
},
StatePowerLevelConfig(:final eventType) =>
userLevel >= (content.events[eventType.type] ?? content.stateDefault),
RedactionPowerLevelConfig(:final targetUser) =>
userLevel >=
(targetUser == user
? (content.events[EventType.redaction.type] ??
content.eventsDefault)
: content.redact),
};
}
static final provider = NotifierProvider.autoDispose
.family<PowerLevelController, bool, PowerLevelConfig>(
PowerLevelController.new,
);
}

View file

@ -1,17 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/profile.dart";
class ProfileController extends AsyncNotifier<Profile> {
final String userId;
ProfileController(this.userId);
@override
Future<Profile> build() {
final client = ref.watch(ClientController.provider.notifier);
return client.getProfile(userId);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<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,187 @@
import "dart:async";
import "dart:math";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/controllers/messages_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/message_config.dart";
import "package:nexus/models/messages_config.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<IList<Event>?> {
class RoomChatController extends AsyncNotifier<InMemoryChatController> {
final String roomId;
RoomChatController(this.roomId);
@override
Future<IList<Event>?> build() async {
Future<InMemoryChatController> build() async {
final client = ref.watch(ClientController.provider.notifier);
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
var room = ref.read(RoomsController.provider)[roomId];
if (room == null) return InMemoryChatController();
final state = await client.getRoomState(
GetRoomStateRequest(
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList == false,
includeMembers: true,
),
);
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) {
final state = await client.getRoomState(.new(roomId: roomId));
room = ref.read(RoomsController.provider)[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 {
final controller = await future;
for (final event in next) {
if (event.type == "m.room.redaction") {
final controller = await future;
final message = controller.messages.firstWhereOrNull(
(message) => message.id == event.content["redacts"],
);
if (message == null || !ref.mounted) return;
await controller.removeMessage(message);
} else {
final message = await ref.watch(
MessageController.provider(
MessageConfig(event: event, 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 &&
!controller.messages.any(
(oldMessage) => oldMessage.id == message.id,
) &&
ref.mounted) {
await controller.insertMessage(message);
}
}
}
}, weak: true).close,
);
ref.onDispose(controller.dispose);
// While there are under 20 messages, try up to two times to load more messages.
for (var i = 0; i < 2 && messages.length < 20; i++) {
await loadOlder(controller);
}
// While there are under 20 events, try to load more
// 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();
return controller;
}
Future<void> deleteMessage(Event event, {String? reason}) => ref
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
roomId: roomId,
reason: reason,
),
);
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"],
);
Future<bool> loadOlder() async {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[roomId]))
?.timeline
.keys;
return oldMessage == null
? controller.insertMessage(message)
: controller.updateMessage(oldMessage, message);
}
Future<void> deleteMessage(Message message, {String? reason}) async {
final controller = await future;
await controller.removeMessage(message);
await ref
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: message.id,
roomId: roomId,
reason: reason,
),
);
}
Future<void> loadOlder([InMemoryChatController? chatController]) async {
final response = await ref
.watch(ClientController.provider.notifier)
.paginate(
.new(
PaginateRequest(
roomId: roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
maxTimelineId: ref
.read(RoomsController.provider)[roomId]
?.timeline
.firstOrNull
?.timelineRowId,
),
);
@ -91,33 +190,51 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
.update(
IMap({
roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
events: response.events.addAll(response.relatedEvents),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
timeline: response.events
.map(
(event) => TimelineRowTuple(
timelineRowId: event.timelineRowId,
eventRowId: event.rowId,
),
)
.toIList(),
),
}),
.new(),
const ISet.empty(),
);
return response.hasMore;
final room = ref.read(RoomsController.provider)[roomId];
if (room == null) return;
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,
);
}
Future<void> send(
String text, {
String message, {
bool shouldMention = true,
required IList<Tag> tags,
required Iterable<Tag> tags,
required RelationType relationType,
Event? relation,
Message? relation,
}) async {
var taggedMessage = text;
var taggedMessage = message;
for (final tag in tags) {
final escaped = RegExp.escape(tag.id);
@ -130,7 +247,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
}
final client = ref.watch(ClientController.provider.notifier);
final event = await client.sendMessage(
client.sendMessage(
SendMessageRequest(
roomId: roomId,
mentions: Mentions(
@ -138,85 +255,50 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
if (shouldMention == true &&
relation != null &&
relationType == RelationType.reply)
relation.sender,
relation.authorId,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
relation: relation == null
? null
: .new(eventId: relation.eventId, relationType: relationType),
: Relation(eventId: relation.id, relationType: relationType),
),
);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
roomId: .new(
events: .new({event.rowId: event}),
sticky: .new({event.rowId}),
),
}),
.new(),
);
}
Future<void> removeReaction(
String reaction,
Event event,
String userId,
) async {
final client = ref.watch(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
.new(
roomId: roomId,
eventId: event.eventId,
relationType: "m.annotation",
),
Future<chat.User> resolveUser(String id) async {
final user = await ref
.watch(ClientController.provider.notifier)
.getProfile(id);
return chat.User(
id: id,
name: user.displayName,
// imageSource: user.avatarUrl == null
// ? null
// : (await ref.watch(
// AvatarController.provider(user.avatarUrl!.toString()).future,
// )).toString(),
);
final reactionEvents = allReactionEvents
?.where((event) => event.redactedBy == null)
.toIList();
final reactionEvent = reactionEvents?.firstWhereOrNull(
(event) => switch (event.content) {
ReactionContent(:final key) =>
key == reaction && event.sender == userId,
_ => false,
},
);
if (reactionEvent != null) {
await ref
.watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
}
}
Future<void> sendReaction(String reaction, Event event) async {
final client = ref.watch(ClientController.provider.notifier);
await client.sendEvent(
.new(
roomId: roomId,
type: EventType.reaction.type,
content: {
"m.relates_to": {
"event_id": event.eventId,
"rel_type": "m.annotation",
"key": reaction,
},
},
synchronous: true,
disableEncryption: true,
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},
),
);
await setFlashing(true);
Timer(Duration(seconds: 1), () => setFlashing(false));
return await controller.scrollToMessage(message.id);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>(
.autoDispose<RoomChatController, InMemoryChatController, String>(
RoomChatController.new,
);
}

View file

@ -1,33 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/room.dart";
class RoomCreatorsController extends Notifier<IList<String>> {
final Room room;
RoomCreatorsController(this.room);
@override
IList<String> build() {
final createRowId = room.state[EventType.create.type]?[""];
final createEvent = createRowId == null ? null : room.events[createRowId];
if (createEvent == null) return .new();
final createEventContent = switch (createEvent.content) {
CreateContent content => content,
_ => null,
};
return switch (createEventContent?.additionalCreatorIds) {
IList<String> creators => creators.add(createEvent.sender),
_ => .new([createEvent.sender]),
};
}
static final provider =
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
RoomCreatorsController.new,
);
}

View file

@ -1,40 +1,13 @@
import "dart:isolate";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart";
class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => .new();
Future<void> addState(
String roomId,
IList<Event> state, {
bool isMembers = false,
}) async => update(
.new({
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
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(),
);
IMap<String, Room> build() => const IMap.empty();
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
final merged = rooms.entries.fold(state, (acc, entry) {
@ -42,41 +15,54 @@ class RoomsController extends Notifier<IMap<String, Room>> {
final incoming = entry.value;
final existing = acc[roomId];
final events = existing?.events.updateById(
incoming.events,
(item) => item.eventId,
);
ref
.watch(NewEventsController.provider(roomId).notifier)
.add(
incoming.timeline
.map(
(timelineTuple) => events?.firstWhereOrNull(
(event) => timelineTuple.eventRowId == event.rowId,
),
)
.nonNulls
.toIList(),
);
return acc.add(
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
sticky:
(incoming.sticky.isEmpty == true
? existing.sticky
: existing.sticky.addAll(incoming.sticky))
.removeWhere(
(rowId) => incoming.timeline.values.contains(rowId),
),
metadata: incoming.metadata ?? existing.metadata,
events: incoming.events.isEmpty
? existing.events
: existing.events.addAll(incoming.events),
events: events!,
state: incoming.state.entries.fold(
existing.state,
(previousValue, event) => previousValue.add(
event.key,
(previousValue[event.key] ?? .new()).addAll(event.value),
(previousValue[event.key] ?? const IMap.empty()).addAll(
event.value,
),
),
),
reset: false,
hasFetchedMembers:
incoming.hasFetchedMembers || existing.hasFetchedMembers,
hasFetchedState:
incoming.hasFetchedState || existing.hasFetchedState,
timeline: (incoming.reset
? incoming.timeline
: existing.timeline.addAll(incoming.timeline)),
timeline:
(incoming.reset
? incoming.timeline
: existing.timeline.updateById(
incoming.timeline,
(item) => item.timelineRowId,
))
.sortedBy((element) => element.timelineRowId)
.toIList(),
receipts: incoming.receipts.entries.fold(
existing.receipts,
(receiptAcc, event) => receiptAcc.add(
event.key,
(receiptAcc[event.key] ?? .new()).addAll(event.value),
(receiptAcc[event.key] ?? IList<ReadReceipt>()).addAll(
event.value,
),
),
),
) ??
@ -88,7 +74,6 @@ class RoomsController extends Notifier<IMap<String, Room>> {
merged,
(acc, roomId) => acc.remove(roomId),
);
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> {
@override
Future<SharedPreferences> build() async => .getInstance();
Future<SharedPreferences> build() => SharedPreferences.getInstance();
static final provider =
AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(

View file

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

View file

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

View file

@ -1,17 +1,11 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/main.dart";
import "package:nexus/models/sync_status.dart";
class SyncStatusController extends Notifier<SyncStatus?> {
@override
Null build() => null;
void set(SyncStatus newStatus) {
if (newStatus.type == .permanentlyFailed) {
showError(newStatus.error ?? "Syncing failed");
}
state = newStatus;
}
void set(SyncStatus newStatus) => state = newStatus;
static final provider = NotifierProvider<SyncStatusController, SyncStatus?>(
SyncStatusController.new,

View file

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

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