Compare commits

..
Author SHA1 Message Date
7b6a4b4181
fix: timeline anchoring 2026-08-10 09:02:17 +02:00
2f3bd7a8ac
fix: timeline pagination 2026-08-10 09:02:17 +02:00
259 changed files with 3681 additions and 5691 deletions

View file

@ -27,7 +27,7 @@ jobs:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }} KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Build app - 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 --target-platform android-arm64" run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --release"
env: env:
KEYSTORE_PATH: ../../keystore.jks KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.47.4 flutter-version: 3.44.4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6

View file

@ -19,29 +19,22 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.47.4 flutter-version: 3.44.4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version-file: gomuks/go.mod go-version-file: gomuks/go.mod
- name: Build App - name: Build app
run: | run: |
flutter pub get flutter pub get
dart scripts/generate.dart dart scripts/generate.dart
flutter pub run build_runner build flutter pub run build_runner build
flutter build macos --release flutter build macos --release
- name: Create DMG - name: Upload installer artifact
id: create-dmg
uses: L-Super/create-dmg-actions@28511e988b13ca34096d439159a66d468b8e724f
with:
dmg_name: nexus
src_dir: build/macos/Build/Products/Release/Nexus.app
- name: Upload DMG
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: nexus.dmg name: Nexus.App
path: ${{ steps.create-dmg.outputs.dmg_path }} path: build/macos/Build/Products/Release/Nexus.app

View file

@ -19,7 +19,8 @@ jobs:
- name: Set up Flutter - name: Set up Flutter
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
with: with:
flutter-version: 3.47.4 flutter-version: 3.44.4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:

1
.gitmodules vendored
View file

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

17
.vscode/launch.json vendored
View file

@ -1,17 +0,0 @@
{
"configurations": [
{
"name": "Flutter",
"type": "dart",
"request": "launch",
"program": "lib/main.dart"
},
{
"name": "Flutter (Headless)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"env": { "FLUTTER_HEADLESS": "1" }
}
]
}

View file

@ -11,8 +11,6 @@
"muks", "muks",
"prefs", "prefs",
"unban", "unban",
"unifiedpush", "unredact"
"unredact",
"webpush"
] ]
} }

View file

@ -21,7 +21,7 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r
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: 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 ```dart
ref.read(MyController.provider.notifier).helperMethod() 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. We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.

View file

@ -17,15 +17,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Platform Support - [x] Platform Support
- [x] Linux - [x] Linux
- [x] Windows - [x] Windows - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
- Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
- [x] Android - [x] Android
- [x] MacOS - [x] MacOS - Unsigned .App only
- [x] Unsigned .ipa - [x] iOS - Unsigned .ipa only
- [ ] App Store
- [x] iOS
- [x] Unsigned .ipa
- [ ] App Store
- [ ] Web (may not be possible) - [ ] Web (may not be possible)
- [x] Login (via OAuth) - [x] Login (via OAuth)
- [x] Rooms / Spaces - [x] Rooms / Spaces
@ -55,10 +50,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Per message profiles - [x] Per message profiles
- [x] Attachments - [x] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Tags - [x] Mentions
- [x] Users - [x] Users
- [x] Rooms - [x] Rooms
- [x] Emojis - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
- [ ] Custom emojis/stickers - [ ] Custom emojis/stickers
- [ ] GIFs using Gomuks' GIF proxies - [ ] GIFs using Gomuks' GIF proxies
- [x] Receiving - [x] Receiving
@ -68,7 +63,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] URL Previews - [x] URL Previews
- [x] Replies - [x] Replies
- [x] Viewing - [x] Viewing
- [x] Jump to original message - [ ] Jump to original message
- [x] In loaded timeline
- [ ] Out of loaded timeline
- [x] Edits - [x] Edits
- [x] Attachments - [x] Attachments
- [x] Unencrypted - [x] Unencrypted
@ -108,8 +105,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Member list - [x] Member list
- [x] Sort by power level - [x] Sort by power level
- [ ] Colors based off of power level - [ ] Colors based off of power level
- [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) - [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [x] Notifications page
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites - [ ] Invites
- [x] Settings - [x] Settings
@ -119,13 +115,13 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
If you want to try out Nexus, grab one of the following artifacts from CI: If you want to try out Nexus, grab one of the following artifacts from CI:
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) - [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip) - [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip) - [Unsigned MacOS App](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/Nexus.App.zip)
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) - [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
- Flatpak - Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
- [NixOS Module](linux/nix/module.nix)
## Build it yourself ## Build it yourself
@ -194,12 +190,6 @@ dart scripts/generate.dart
> export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include" > export CPATH="$(clang -v 2>&1 | grep "Selected GCC installation" | rev | cut -d' ' -f1 | rev)/include"
> ``` > ```
Build webcrypto:
```sh
flutter pub run webcrypto:setup
```
Build generated files, and watch for new changes: Build generated files, and watch for new changes:
```sh ```sh

View file

@ -6,12 +6,6 @@ analyzer:
- "build/**" - "build/**"
- "**/*.g.dart" - "**/*.g.dart"
- "**/*.freezed.dart" - "**/*.freezed.dart"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
rules: rules:

77
android/app/build.gradle Normal file
View file

@ -0,0 +1,77 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader("UTF-8") { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
if (flutterVersionCode == null) {
flutterVersionCode = "1"
}
def flutterVersionName = localProperties.getProperty("flutter.versionName")
if (flutterVersionName == null) {
flutterVersionName = "1.0"
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "nexus.federated.nexus"
ndkVersion = flutter.ndkVersion
compileSdk = 34
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
applicationId = "nexus.federated.nexus"
minSdk = 29
targetSdkVersion flutter.targetSdkVersion
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
}
signingConfigs {
release {
keyAlias "key"
def storePath = keystoreProperties['path'] ?: System.getenv("KEYSTORE_PATH")
storeFile storePath ? file(storePath) : null
keyPassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD")
storePassword keystoreProperties['password'] ?: System.getenv("KEYSTORE_PASSWORD")
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix = ".debug"
}
}
}
flutter {
source = "../.."
}

View file

@ -1,84 +0,0 @@
import java.util.Properties
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use {
keystoreProperties.load(it)
}
}
android {
namespace = "nexus.federated.nexus"
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
applicationId = "nexus.federated.nexus"
minSdk = 29
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
}
signingConfigs {
create("release") {
keyAlias = "key"
val storePath =
keystoreProperties["path"]?.toString()
?: System.getenv("KEYSTORE_PATH")
storeFile = storePath?.let { file(it) }
keyPassword =
keystoreProperties["password"]?.toString()
?: System.getenv("KEYSTORE_PASSWORD")
storePassword =
keystoreProperties["password"]?.toString()
?: System.getenv("KEYSTORE_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
debug {
applicationIdSuffix = ".debug"
}
}
}
dependencies {
implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
}
}
flutter {
source = "../.."
}

26
android/build.gradle Normal file
View file

@ -0,0 +1,26 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
afterEvaluate {project ->
if (project.hasProperty("android")) {
android {
compileSdkVersion 36
buildToolsVersion '33.0.1'
}
}
}
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View file

@ -1,25 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip

25
android/settings.gradle Normal file
View file

@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.13.0" apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
}
include ":app"

View file

@ -1,26 +0,0 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
}
include(":app")

View file

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 MiB

BIN
assets/twim/oauth.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
assets/twim/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

29
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1788450739, "lastModified": 1785627969,
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=", "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993", "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -42,16 +42,15 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1790043059, "lastModified": 1774860670,
"narHash": "sha256-7ID4HL4IrrlalCsxMASkcAr+v0VHTNFp6+sZ+dpvOV8=", "narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=",
"owner": "Henry-Hiles", "owner": "neobrain",
"repo": "nix2flatpak", "repo": "nix2flatpak",
"rev": "290dd282e555cab988c021c2be5f2361e58c4d51", "rev": "61d68e21e3fbc2d57590051f48736bea271f4aba",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "Henry-Hiles", "owner": "neobrain",
"ref": "quad/nexus",
"repo": "nix2flatpak", "repo": "nix2flatpak",
"type": "github" "type": "github"
} }
@ -74,11 +73,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1788057806, "lastModified": 1785031560,
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=", "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c", "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -89,11 +88,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1790185690, "lastModified": 1785967620,
"narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=", "narHash": "sha256-IItrdb7Puk05RqOBWZYFC5X6Wl1sJmCfh5MWVHw5iMM=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4975466d324710c576dc11ad614684e6bd8cad8e", "rev": "b7c2ada94fe99c15b0dbcf4d11fd7850b957a436",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -5,7 +5,7 @@
self.submodules = true; self.submodules = true;
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
nix2flatpak.url = "github:Henry-Hiles/nix2flatpak/quad/nexus"; nix2flatpak.url = "github:neobrain/nix2flatpak";
}; };
outputs = outputs =
@ -16,12 +16,62 @@
... ...
}@inputs: }@inputs:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ ./linux/nix ];
systems = [ systems = [
"x86_64-linux" "x86_64-linux"
"aarch64-linux" "aarch64-linux"
"aarch64-darwin" "aarch64-darwin"
"x86_64-darwin" "x86_64-darwin"
]; ];
perSystem =
{
lib,
pkgs,
system,
...
}:
{
_module.args.pkgs = import nixpkgs {
inherit system;
config = {
android_sdk.accept_license = true;
allowUnfree = true;
};
};
packages =
let
default = pkgs.callPackage ./linux/nix/pkg {
src = self;
};
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 = [
"pulseaudio"
"fallback-x11"
"wayland"
];
devices = [ "dri" ];
};
};
gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix {
src = self;
};
};
devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { };
};
}; };
} }

2
gomuks

@ -1 +1 @@
Subproject commit 480938f9e01e7eae2cf249657efae03df76599d6 Subproject commit 9444dd293664c300c6e710bb6cddcd9c6cab365d

View file

@ -1,5 +1,4 @@
import "dart:io"; import "dart:io";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:hooks/hooks.dart"; import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart"; import "package:code_assets/code_assets.dart";
@ -51,10 +50,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
break; break;
case OS.macOS: case OS.macOS:
libFileName = "libgomuks.dylib"; libFileName = "libgomuks.dylib";
extraEnv = { extraEnv = {"SDKROOT": await getXCodeTool()};
"SDKROOT": await getXCodeTool(),
"MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(),
};
break; break;
case OS.windows: case OS.windows:
libFileName = "libgomuks.dll"; libFileName = "libgomuks.dll";
@ -135,8 +131,6 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"go", "go",
[ [
"build", "build",
"-trimpath",
"-ldflags=-s -w",
"-tags", "-tags",
tags, tags,
"-o", "-o",

View file

@ -7,7 +7,6 @@ import UIKit
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 712 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

View file

@ -6,8 +6,10 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:path/path.dart"; import "package:path/path.dart";
class AttachmentController(final String roomId) class AttachmentController extends Notifier<(String, MessageContent?)?> {
extends Notifier<(String, MessageContent?)?> { final String roomId;
AttachmentController(this.roomId);
@override @override
Null build() => null; Null build() => null;

View file

@ -4,8 +4,10 @@ import "package:nexus/controllers/client_id.dart";
import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart";
class AuthUrlController(final Uri homeserver) class AuthUrlController extends AsyncNotifier<OAuthAuthCodeResponse> {
extends AsyncNotifier<OAuthAuthCodeResponse> { final Uri homeserver;
AuthUrlController(this.homeserver);
@override @override
Future<OAuthAuthCodeResponse> build() async => ref Future<OAuthAuthCodeResponse> build() async => ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)

View file

@ -1,17 +1,19 @@
import "dart:async"; import "dart:async";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/user.dart"; import "package:nexus/controllers/user.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class AuthorController(final Event event) class AuthorController extends AsyncNotifier<MembershipContent> {
extends AsyncNotifier<MembershipContent> { final Event event;
AuthorController(this.event);
@override @override
Future<MembershipContent> build() async { Future<MembershipContent> build() async {
final member = await ref.watch( final member = await ref.watch(
UserController.provider(.new(roomId: event.roomId, userId: event.sender)) UserController.provider(
.future, .new(roomId: event.roomId, userId: event.sender),
).future,
); );
return .new( return .new(

View file

@ -1,28 +1,26 @@
import "dart:async";
import "dart:ffi"; import "dart:ffi";
import "dart:io"; import "dart:io";
import "dart:isolate"; import "dart:isolate";
import "dart:math"; import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
import "package:flutter/cupertino.dart"; import "package:flutter/foundation.dart";
import "package:intl/intl.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/capabilities.dart"; import "package:nexus/main.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/event_context.dart";
import "package:nexus/models/gomuks_config.dart";
import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/oauth_auth_code_response.dart";
import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/open_graph_data.dart";
import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate.dart";
import "package:nexus/models/paginate_manual.dart";
import "package:nexus/models/requests/deregister_pusher.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_event.dart";
import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
@ -31,9 +29,7 @@ import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart";
import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/oauth/register_client.dart";
import "package:nexus/models/requests/paginate.dart"; import "package:nexus/models/requests/paginate.dart";
import "package:nexus/models/requests/paginate_manual.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/report.dart";
import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_event.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
@ -42,9 +38,8 @@ import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_metadata.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/models/spec_versions_response.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart"; import "package:path_provider/path_provider.dart";
@ -52,99 +47,134 @@ import "package:path_provider/path_provider.dart";
class ClientController extends AsyncNotifier<int> { class ClientController extends AsyncNotifier<int> {
@override @override
Future<int> build() async { Future<int> build() async {
debugPrint("Setting Gomuks env..."); final Pointer<Char> root;
if (Platform.isAndroid || Platform.isIOS) { if (Platform.isAndroid || Platform.isIOS) {
final env = { final dir = await getApplicationSupportDirectory();
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path, root = "${dir.path}/gomuks".toNativeUtf8().cast();
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path, } else {
}; root = nullptr.cast();
for (final MapEntry(:key, :value) in env.entries) {
final keyPtr = key.toNativeUtf8().cast<Char>();
final valuePtr = value.toNativeUtf8().cast<Char>();
try {
GomuksSetEnv(keyPtr, valuePtr);
} finally {
calloc
..free(keyPtr)
..free(valuePtr);
}
}
} }
debugPrint("Initializing Gomuks..."); final handle = GomuksInit(root);
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
initialDeviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
),
).toJson().toGomuksBufferPtr();
final callable =
NativeCallable<
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
>.listener((
Pointer<Char> command,
int requestId,
GomuksOwnedBuffer data,
) {
try { try {
return GomuksInit(bufferPointer.ref); final muksEventType = command.cast<Utf8>().toDartString();
} finally { debugPrint("Handling $muksEventType...");
calloc final decodedMuksEvent = data.toJson();
..free(bufferPointer.ref.base)
..free(bufferPointer); switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.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);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
}
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.watch(accountDataProvider.notifier)
.update(syncData.accountData);
if (syncData.topLevelSpaces != null) {
ref
.watch(TopLevelSpacesController.provider.notifier)
.set(syncData.topLevelSpaces!);
}
if (syncData.spaceEdges != null) {
ref
.watch(SpaceEdgesController.provider.notifier)
.set(syncData.spaceEdges!);
}
// ref
// .watch(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
} }
}); });
ref.onDispose(() => GomuksDestroy(handle)); ref.onDispose(() => GomuksDestroy(handle));
ref.onDispose(callable.close);
return handle; final errorCode = GomuksStart(handle, callable.nativeFunction);
if (errorCode == 0) return handle;
throw Exception("GomuksStart returned error code $errorCode");
} }
Future<dynamic> callGomuksMethod( Future<dynamic> _sendCommand(
Map<String, dynamic> data, String command, [
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data) Map<String, dynamic> data = const {},
callback, ]) async {
) async {
final bufferPointer = data.toGomuksBufferPtr(); final bufferPointer = data.toGomuksBufferPtr();
try {
final handle = await future; final handle = await future;
final response = await Isolate.run( final response = await Isolate.run(
() => callback(handle, bufferPointer.ref), () => GomuksSubmitCommand(
handle,
command.toNativeUtf8().cast<Char>(),
bufferPointer.ref,
),
); );
final json = response.buf.toJson(); calloc.free(bufferPointer);
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") { if (response.command.cast<Utf8>().toDartString() == "error") {
throw json; throw json;
} }
return json; return json;
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
} }
}
Future<(Event, RoomMetadata)> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
return (
Event.fromJson(response["event"]),
RoomMetadata.fromJson(response["room"]),
);
}
dynamic _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) {
final commandPointer = command.toNativeUtf8().cast<Char>();
try {
return GomuksSubmitCommand(handle, commandPointer, data);
} finally {
calloc.free(commandPointer);
}
});
Future<void> redactEvent(RedactEventRequest report) => Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson()); _sendCommand("redact_event", report.toJson());
@ -183,17 +213,21 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<String> joinRoom(JoinRoomRequest request) async => Future<String> joinRoom(JoinRoomRequest request) async {
(await _sendCommand("join_room", request.toJson()))["room_id"]; final response = await _sendCommand("join_room", request.toJson());
return response["room_id"];
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async => }
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
Future<void> leaveRoom(Room room) async { Future<void> leaveRoom(Room room) async {
if (room.metadata == null) return; if (room.metadata == null) return;
await _sendCommand("leave_room", {"room_id": room.metadata!.id}); await _sendCommand("leave_room", {"room_id": room.metadata!.id});
} }
// (await _sendCommand("get_event_context", {
// "room_id": request.roomId,
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
// }));
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async { Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
Future<List?> getState(GetRoomStateRequest request) async => Future<List?> getState(GetRoomStateRequest request) async =>
(await _sendCommand("get_room_state", request.toJson())) as List?; (await _sendCommand("get_room_state", request.toJson())) as List?;
@ -214,12 +248,6 @@ class ClientController extends AsyncNotifier<int> {
return .new(response?.map((event) => .fromJson(event))); return .new(response?.map((event) => .fromJson(event)));
} }
Future<IList<Event>> getMentions(GetMentionsRequest request) async => .new(
// TODO: Handle `related_events`
((await _sendCommand("get_mentions", request.toJson()))["events"] as List)
.map((event) => .fromJson(event)),
);
Future<Event?> getEvent(GetEventRequest request) async { Future<Event?> getEvent(GetEventRequest request) async {
final json = await _sendCommand("get_event", request.toJson()); final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json); return json == null ? null : .fromJson(json);
@ -231,19 +259,8 @@ class ClientController extends AsyncNotifier<int> {
Future<Paginate> paginate(PaginateRequest request) async => Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson())); .fromJson(await _sendCommand("paginate", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async => Future<ProfileResponse> getProfile(String userId) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson())); .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async {
try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
} catch (_) {
return ProfileResponse(profile: .new(id: userId));
}
}
Future<void> reportEvent(ReportRequest request) => Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson()); _sendCommand("report_event", request.toJson());
@ -254,15 +271,6 @@ class ClientController extends AsyncNotifier<int> {
Future<void> setAccountData(SetAccountDataRequest request) => Future<void> setAccountData(SetAccountDataRequest request) =>
_sendCommand("set_account_data", request.toJson()); _sendCommand("set_account_data", request.toJson());
Future<void> registerPusher(RegisterPusherRequest request) =>
_sendCommand("register_homeserver_push", request.toJson());
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
_sendCommand("register_homeserver_push", {
...request.toJson(),
"kind": null,
});
Future<MessageContent> uploadMedia(UploadMediaRequest request) async => Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
.fromJson(await _sendCommand("upload_media", request.toJson())); .fromJson(await _sendCommand("upload_media", request.toJson()));
@ -272,11 +280,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout"); Future<void> logout() => _sendCommand("logout");
Future<void> markRead(Room room) async { Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId]; final event = eventRowId == null ? null : room.events[eventRowId];
if (event == null || room.metadata == null) return;
if (event == null) return;
await _sendCommand("mark_read", { await _sendCommand("mark_read", {
"room_id": room.metadata!.id, "room_id": room.metadata!.id,
@ -302,14 +308,10 @@ class ClientController extends AsyncNotifier<int> {
Future<SpecVersionsResponse> getSpecVersions() async => Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions")); .fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
(await _sendCommand("get_capabilities"))["capabilities"],
);
Future<Uri?> discoverHomeserver(Uri homeserver) async { Future<Uri?> discoverHomeserver(Uri homeserver) async {
try { try {
final response = await _sendCommand("discover_homeserver", { final response = await _sendCommand("discover_homeserver", {
"user_id": "@fake-user:${homeserver.authority}", "user_id": "@fake-user:${homeserver.host}",
}); });
return Uri.parse(response["m.homeserver"]?["base_url"]); return Uri.parse(response["m.homeserver"]?["base_url"]);
} catch (error) { } catch (error) {

View file

@ -1,7 +1,10 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> { class ClientIdController extends AsyncNotifier<String> {
final Uri homeserver;
ClientIdController(this.homeserver);
@override @override
Future<String> build() => ref Future<String> build() => ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)

View file

@ -0,0 +1,84 @@
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

@ -5,8 +5,10 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_event.dart";
class EventController(final GetEventRequest request) class EventController extends AsyncNotifier<Event?> {
extends AsyncNotifier<Event?> { final GetEventRequest request;
EventController(this.request);
@override @override
Future<Event?> build() async { Future<Event?> build() async {
final room = ref.watch( final room = ref.watch(

View file

@ -1,124 +0,0 @@
import "dart:async";
import "dart:ffi";
import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:ffi/ffi.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class GomuksListenerController extends AsyncNotifier<void> {
@override
Future<void> build() async {
debugPrint("Starting gomuks...");
final handle = await ref.watch(ClientController.provider.future);
final callable =
NativeCallable<
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
>.listener((
Pointer<Char> command,
int requestId,
GomuksOwnedBuffer data,
) {
try {
final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJson();
switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.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);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
}
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.watch(accountDataProvider.notifier)
.update(syncData.accountData);
if (syncData.topLevelSpaces != null) {
ref
.watch(TopLevelSpacesController.provider.notifier)
.set(syncData.topLevelSpaces!);
}
if (syncData.spaceEdges != null) {
ref
.watch(SpaceEdgesController.provider.notifier)
.set(syncData.spaceEdges!);
}
// ref
// .watch(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
}
});
ref.onDispose(callable.close);
final errorCode = GomuksStart(handle, callable.nativeFunction);
if (errorCode != 0) {
throw Exception("GomuksStart returned error code $errorCode");
}
}
static final provider = AsyncNotifierProvider<GomuksListenerController, void>(
GomuksListenerController.new,
);
}

View file

@ -1,16 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class JumpToEventController(String? _) extends Notifier<String?> {
@override
String? build() => null;
void set(String? eventId) => state = eventId;
@override
bool updateShouldNotify(_, _) => true;
static final provider = NotifierProvider.family
.autoDispose<JumpToEventController, String?, String?>(
JumpToEventController.new,
);
}

View file

@ -1,28 +1,30 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/shared_prefs.dart";
class KeyController(final String key) extends AsyncNotifier<String?> { class KeyController extends Notifier<String?> {
final String key;
KeyController(this.key);
static const String spaceKey = "space"; static const String spaceKey = "space";
static const String roomKey = "room"; static const String roomKey = "room";
static const String pushKeyKey = "pushKey";
@override @override
Future<String?> build() => String? build() =>
ref.watch(SharedPrefsController.provider).getString(key); ref.watch(SharedPrefsController.provider).requireValue.getString(key);
Future<void> set(String? value) async { Future<void> set(String? value) async {
final prefs = ref.watch(SharedPrefsController.provider); final prefs = ref.watch(SharedPrefsController.provider).requireValue;
state = .data(value); state = value;
if (value == null) { if (value == null) {
await prefs.remove(key); prefs.remove(key);
} else { } else {
await prefs.setString(key, value); prefs.setString(key, value);
} }
} }
static final provider = static final provider =
AsyncNotifierProvider.family<KeyController, String?, String>( NotifierProvider.family<KeyController, String?, String>(
KeyController.new, KeyController.new,
); );
} }

View file

@ -1,20 +1,22 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/shared_prefs.dart";
class MemberListOpenedController extends AsyncNotifier<bool> { class MemberListOpenedController extends Notifier<bool> {
static const String key = "memberListOpened"; static const String key = "memberListOpened";
@override @override
Future<bool> build() async => bool build() =>
await ref.watch(SharedPrefsController.provider).getBool(key) ?? true; ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ??
true;
Future<void> set(bool value) async { Future<void> set(bool value) async {
state = .data(value); final prefs = ref.watch(SharedPrefsController.provider).requireValue;
await ref.watch(SharedPrefsController.provider).setBool(key, value); state = value;
prefs.setBool(key, value);
} }
static final provider = static final provider = NotifierProvider<MemberListOpenedController, bool>(
AsyncNotifierProvider<MemberListOpenedController, bool>(
MemberListOpenedController.new, MemberListOpenedController.new,
); );
} }

View file

@ -6,8 +6,10 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_room_state.dart";
class MembersController(final String roomId) class MembersController extends AsyncNotifier<ISet<Event>> {
extends AsyncNotifier<ISet<Event>> { final String roomId;
MembersController(this.roomId);
@override @override
Future<ISet<Event>> build() async { Future<ISet<Event>> build() async {
final room = ref.watch( final room = ref.watch(

View file

@ -5,8 +5,10 @@ import "package:nexus/models/configs/members_by_status.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class MembersByStatusController(final MembersByStatusConfig config) class MembersByStatusController extends AsyncNotifier<ISet<Event>> {
extends AsyncNotifier<ISet<Event>> { final MembersByStatusConfig config;
MembersByStatusController(this.config);
@override @override
Future<ISet<Event>> build() => ref.watch( Future<ISet<Event>> build() => ref.watch(
MembersController.provider(config.roomId).selectAsync( MembersController.provider(config.roomId).selectAsync(

View file

@ -8,8 +8,11 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class MembersGroupedController(final MembersByStatusConfig config) class MembersGroupedController
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> { extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
final MembersByStatusConfig config;
MembersGroupedController(this.config);
@override @override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async { Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch( final room = ref.watch(

View file

@ -1,15 +1,14 @@
import "dart:async"; import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
class MultiProviderController(final IList<AsyncNotifierProvider> providers) class MultiProviderController extends AsyncNotifier<void> {
extends AsyncNotifier<void> { MultiProviderController(this.providers);
final IList<AsyncNotifierProvider> providers;
@override @override
Future<void> build() => .wait( Future<void> build() =>
providers.map((provider) => ref.watch(provider.future)), .wait(providers.map((provider) => ref.watch(provider.future)));
eagerError: true,
);
static final provider = static final provider =
AsyncNotifierProvider.family< AsyncNotifierProvider.family<

View file

@ -1,162 +0,0 @@
import "dart:async";
import "dart:io";
import "package:flutter/services.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/portal.dart";
import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/pages/notifications.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class NotificationController
extends AsyncNotifier<FlutterLocalNotificationsPlugin> {
@override
Future<FlutterLocalNotificationsPlugin> build() async {
final notifications = FlutterLocalNotificationsPlugin();
if (!Platform.isLinux) {
final darwin = DarwinInitializationSettings();
await notifications.initialize(
settings: .new(
windows: .new(
appName: "Nexus",
appUserModelId: "nexus.federated.nexus",
guid: "dde78daf-130f-4e46-a80a-e31deeab45d7",
),
android: .new("ic_launcher_foreground"),
iOS: darwin,
macOS: darwin,
),
onDidReceiveNotificationResponse: (details) {
if (details.payload case final eventId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
),
),
);
}
}
},
);
}
if (Platform.isLinux) {
const notificationChannel = MethodChannel("nexus/notifications");
notificationChannel.setMethodCallHandler((call) async {
if (call.method != "notificationClicked") {
return;
}
final eventId = call.arguments as String;
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
highlightedEventId: eventId,
defaultToAllNotifications: true,
),
),
);
}
});
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
}
return notifications;
}
Future<bool> requestPermissions() async {
if (Platform.isLinux) {
return true;
}
final controller = await future;
if (Platform.isIOS) {
return await controller
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isMacOS) {
return await controller
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isAndroid) {
return await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
true;
}
return true;
}
Future<void> send({
required int id,
required String title,
String? body,
File? icon,
String? payload,
}) async {
debugPrint("Sending notification for $id");
if (Platform.isLinux) {
final portal = await ref.watch(PortalController.provider.future);
await portal.notification.addNotification(
id.toString(),
title: title,
body: body,
icon: icon == null ? null : XdgNotificationIconFile(icon.path),
defaultAction: "app.event",
defaultActionTarget: payload,
);
} else {
final notificationDetails = NotificationDetails(
android: .new(
"messages",
"Messages",
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
),
// TODO: See if icons can be added to iOS, macOS, and Windows
// notifications (#68)
iOS: .new(),
macOS: .new(),
windows: .new(),
);
final controller = await future;
await controller.show(
id: id,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: payload,
);
}
}
static final provider =
AsyncNotifierProvider<
NotificationController,
FlutterLocalNotificationsPlugin
>(NotificationController.new);
}

View file

@ -1,53 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
@override
Future<IList<Event>> build() async {
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
return await client.getMentions(
.new(
maxTimestamp: .now(),
unreadType: unreadType ?? .highlight,
roomId: roomId,
),
);
}
Future<void> loadOlder() async {
final currentNotifications = await future;
state = .loading();
state = await .guard(() async {
final lastTs = currentNotifications.lastOrNull?.timestamp;
if (lastTs == null) return const .empty();
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
roomId: roomId,
),
);
return currentNotifications.addAll(newNotifications);
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
NotificationsController,
IList<Event>,
NotificationsRequest?
>(NotificationsController.new);
}

View file

@ -4,8 +4,10 @@ import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/pinned_ids.dart"; import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class PinnedEventsController(final String roomId) class PinnedEventsController extends AsyncNotifier<IList<Event>> {
extends AsyncNotifier<IList<Event>> { final String roomId;
PinnedEventsController(this.roomId);
@override @override
Future<IList<Event>> build() async { Future<IList<Event>> build() async {
final pinIds = ref.watch(PinnedIdsController.provider(roomId)); final pinIds = ref.watch(PinnedIdsController.provider(roomId));
@ -13,8 +15,9 @@ class PinnedEventsController(final String roomId)
return (await Future.wait( return (await Future.wait(
pinIds.map( pinIds.map(
(eventId) => ref.watch( (eventId) => ref.watch(
EventController.provider(.new(eventId: eventId, roomId: roomId)) EventController.provider(
.future, .new(eventId: eventId, roomId: roomId),
).future,
), ),
), ),
)).nonNulls.toIList(); )).nonNulls.toIList();

View file

@ -5,7 +5,10 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/pinned_events.dart";
class PinnedIdsController(final String roomId) extends Notifier<IList<String>> { class PinnedIdsController extends Notifier<IList<String>> {
final String roomId;
PinnedIdsController(this.roomId);
@override @override
IList<String> build() { IList<String> build() {
final room = ref.watch( final room = ref.watch(

View file

@ -1,22 +0,0 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class PortalController extends AsyncNotifier<XdgDesktopPortalClient> {
@override
Future<XdgDesktopPortalClient> build() async {
final portal = XdgDesktopPortalClient();
if (!await File("/.flatpak-info").exists()) {
await portal.registerApplication("nexus.federated.nexus");
}
ref.onDispose(portal.close);
return portal;
}
static final provider =
AsyncNotifierProvider<PortalController, XdgDesktopPortalClient>(
PortalController.new,
);
}

View file

@ -6,8 +6,10 @@ import "package:nexus/models/configs/power_level.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
class PowerLevelController(final PowerLevelConfig config) class PowerLevelController extends Notifier<bool> {
extends Notifier<bool> { final PowerLevelConfig config;
PowerLevelController(this.config);
@override @override
bool build() { bool build() {
if (config case EventPowerLevelConfig(:final eventType)) { if (config case EventPowerLevelConfig(:final eventType)) {

View file

@ -2,11 +2,13 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/profile_response.dart"; import "package:nexus/models/profile_response.dart";
class ProfileController(final String userId) class ProfileController extends AsyncNotifier<ProfileResponse> {
extends AsyncNotifier<ProfileResponse> { final String userId;
ProfileController(this.userId);
@override @override
Future<ProfileResponse> build() { Future<ProfileResponse> build() {
final client = ref.read(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
return client.getProfile(userId); return client.getProfile(userId);
} }

View file

@ -1,31 +0,0 @@
import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/key.dart";
class PushKeyController(final String instance) extends AsyncNotifier<String?> {
@override
Future<String?> build() async => json.decode(
await ref.watch(KeyController.provider(KeyController.pushKeyKey).future) ??
"{}",
)[instance];
Future<void> set(String? value) async {
final provider = KeyController.provider(KeyController.pushKeyKey);
final notifier = ref.watch(provider.notifier);
final prefs = IMap(json.decode(await ref.watch(provider.future) ?? "{}"));
state = .data(value);
notifier.set(
json.encode(
prefs.add(instance, value).where((_, value) => value != null).unlock,
),
);
}
static final provider =
AsyncNotifierProvider.family<PushKeyController, String?, String>(
PushKeyController.new,
);
}

View file

@ -5,8 +5,10 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/configs/reactions.dart"; import "package:nexus/models/configs/reactions.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
class ReactionsController(final ReactionsConfig config) class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> {
extends AsyncNotifier<IMap<String, IList<String>>> { final ReactionsConfig config;
ReactionsController(this.config);
@override @override
Future<IMap<String, IList<String>>> build() async { Future<IMap<String, IList<String>>> build() async {
final eventInfo = ref.watch( final eventInfo = ref.watch(

View file

@ -1,44 +0,0 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart";
class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
@override
IList<RecentEmoji> build() => ref.watch(
AccountDataController.provider.select(
(value) => value.recentEmoji.recentEmoji,
),
);
Future<void> add(String emoji) => ref
.watch(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.recentEmojiKey,
content: RecentEmojiData(
recentEmoji: .new([
.new(
emoji: emoji,
total:
(state
.firstWhereOrNull(
(element) => element.emoji == emoji,
)
?.total ??
0) +
1,
),
...state.whereNot((element) => element.emoji == emoji),
]),
),
),
);
static final provider =
NotifierProvider.autoDispose<RecentEmojiController, IList<RecentEmoji>>(
RecentEmojiController.new,
);
}

View file

@ -1,6 +1,5 @@
import "dart:async"; import "dart:async";
import "dart:math"; import "dart:math";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -10,36 +9,45 @@ import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
class RoomChatController(final (String roomId, String? contextualEvent) info) class RoomChatController extends AsyncNotifier<IList<Event>?> {
extends AsyncNotifier<RoomChat?> { final String roomId;
Future<bool>? _loadingOlder;
RoomChatController(this.roomId);
@override @override
Future<RoomChat?> build() async { Future<IList<Event>?> build() async {
final (roomId, eventId) = info; final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier); final initialRoom = ref.watch(
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
if (room == null) return null; if (initialRoom == null) return null;
Room room = initialRoom;
if (!room.hasFetchedState) { if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId)); final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state); await ref.read(RoomsController.provider.notifier).addState(roomId, state);
room = ref.read(RoomsController.provider)[roomId] ?? room;
} }
final timeline = room.timeline // Load one more page when the initial timeline is short.
if (room.hasMore && room.timeline.length < 20) {
await loadOlder();
room = ref.read(RoomsController.provider)[roomId] ?? room;
}
return room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value) .map((element) => element.value)
.toIList() .toIList()
.addAll(room.clientSticky) .addAll(room.sticky)
.map((entry) { .map((entry) {
final foundEvent = entry == null ? null : room.events[entry]; final foundEvent = entry == null ? null : room.events[entry];
@ -57,26 +65,6 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
}) })
.nonNulls .nonNulls
.toIList(); .toIList();
if (info.$2 == null || timeline.map((e) => e.eventId).contains(info.$2)) {
ref.watch(RoomsController.provider.select((rooms) => rooms[roomId]));
return .new(
timeline: timeline,
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
final context = await client.getEventContext(
.new(roomId: roomId, eventId: info.$2!),
);
return .new(
timeline: context.before.add(context.event).addAll(context.after),
hasMoreBackward: true,
hasMoreForward: true,
historicalData: .new(start: context.start, end: context.end),
);
}
} }
Future<void> deleteMessage(Event event, {String? reason}) => ref Future<void> deleteMessage(Event event, {String? reason}) => ref
@ -84,49 +72,36 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
.redactEvent( .redactEvent(
RedactEventRequest( RedactEventRequest(
eventId: event.eventId, eventId: event.eventId,
roomId: info.$1, roomId: roomId,
reason: reason, reason: reason,
), ),
); );
Future<void> paginate(Direction direction) async { Future<bool> loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() {
if (state.isLoading) return; _loadingOlder = null;
});
final chat = await future; Future<bool> _loadOlder() async {
final room = ref.read(RoomsController.provider)[roomId];
if (room == null || !room.hasMore) return false;
if (direction == .forward final timelineKeys = room.timeline.keys;
? chat?.hasMoreForward == false final response = await ref
: chat?.hasMoreBackward == false) { .watch(ClientController.provider.notifier)
return; .paginate(
}
state = .loading();
final client = ref.read(ClientController.provider.notifier);
if (chat?.historicalData == null) {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[info.$1]))
?.timeline
.keys;
final response = await client.paginate(
.new( .new(
roomId: info.$1, roomId: roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true maxTimelineId: timelineKeys.isNotEmpty
? timelineKeys?.reduce(min) ? timelineKeys.reduce(min)
: null, : null,
), ),
); );
if (response.events.isEmpty) {
state = .data(state.value);
}
ref ref
.read(RoomsController.provider.notifier) .watch(RoomsController.provider.notifier)
.update( .update(
IMap({ IMap({
info.$1: Room( roomId: Room(
events: IMap.fromIterable( events: IMap.fromIterable(
response.events.addAll(response.relatedEvents), response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId, keyMapper: (event) => event.rowId,
@ -142,43 +117,8 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
}), }),
.new(), .new(),
); );
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.$1,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
state = .data( return response.hasMore;
.new(
timeline: direction == .forward
? chat.timeline.addAll(paginationResponse.events)
: paginationResponse.events.addAll(chat.timeline),
hasMoreForward:
direction == .forward && paginationResponse.nextBatch == null
? false
: chat.hasMoreForward,
hasMoreBackward:
direction == .backward && paginationResponse.nextBatch == null
? false
: chat.hasMoreBackward,
historicalData: chat.historicalData?.copyWith(
start:
(direction == .backward
? paginationResponse.nextBatch
: null) ??
chat.historicalData!.start,
end:
(direction == .forward ? paginationResponse.nextBatch : null) ??
chat.historicalData!.end,
),
),
);
}
} }
Future<void> send( Future<void> send(
@ -192,8 +132,8 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
if (relationType == .edit) { if (relationType == .edit) {
baseContent = relation?.content; baseContent = relation?.content;
} else { } else {
final provider = AttachmentController.provider(info.$1); final provider = AttachmentController.provider(roomId);
baseContent = ref.read(provider)?.$2; baseContent = ref.watch(provider)?.$2;
ref.invalidate(provider); ref.invalidate(provider);
} }
@ -209,10 +149,10 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
); );
} }
final client = ref.read(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final event = await client.sendMessage( final event = await client.sendMessage(
SendMessageRequest( SendMessageRequest(
roomId: info.$1, roomId: roomId,
baseContent: baseContent, baseContent: baseContent,
mentions: Mentions( mentions: Mentions(
userIds: [ userIds: [
@ -231,12 +171,12 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
); );
ref ref
.read(RoomsController.provider.notifier) .watch(RoomsController.provider.notifier)
.update( .update(
.new({ .new({
info.$1: .new( roomId: .new(
events: .new({event.rowId: event}), events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}), sticky: .new({event.rowId}),
), ),
}), }),
.new(), .new(),
@ -248,10 +188,10 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
Event event, Event event,
String userId, String userId,
) async { ) async {
final client = ref.read(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents( final allReactionEvents = await client.getRelatedEvents(
.new( .new(
roomId: info.$1, roomId: roomId,
eventId: event.eventId, eventId: event.eventId,
relationType: "m.annotation", relationType: "m.annotation",
), ),
@ -272,16 +212,16 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
if (reactionEvent != null) { if (reactionEvent != null) {
await ref await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: info.$1)); .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
} }
} }
Future<void> sendReaction(String reaction, Event event) async { Future<void> sendReaction(String reaction, Event event) async {
final client = ref.read(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
await client.sendEvent( await client.sendEvent(
.new( .new(
roomId: info.$1, roomId: roomId,
type: EventType.reaction.type, type: EventType.reaction.type,
content: ReactionContent(key: reaction), content: ReactionContent(key: reaction),
synchronous: true, synchronous: true,
@ -293,7 +233,7 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, (String, String?)>( .autoDispose<RoomChatController, IList<Event>?, String>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -4,7 +4,10 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> { class RoomCreatorsController extends Notifier<IList<String>> {
final Room room;
RoomCreatorsController(this.room);
@override @override
IList<String> build() { IList<String> build() {
final createRowId = room.state[EventType.create.type]?[""]; final createRowId = room.state[EventType.create.type]?[""];

View file

@ -1,16 +0,0 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/room_summary.dart";
class RoomSummaryController(final JoinRoomRequest request)
extends AsyncNotifier<RoomSummary> {
@override
Future<RoomSummary> build() =>
ref.read(ClientController.provider.notifier).getRoomSummary(request);
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
RoomSummaryController.new,
);
}

View file

@ -1,5 +1,4 @@
import "dart:isolate"; import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
@ -47,10 +46,10 @@ class RoomsController extends Notifier<IMap<String, Room>> {
roomId, roomId,
existing?.copyWith( existing?.copyWith(
hasMore: incoming.hasMore, hasMore: incoming.hasMore,
clientSticky: sticky:
(incoming.clientSticky.isEmpty == true (incoming.sticky.isEmpty == true
? existing.clientSticky ? existing.sticky
: existing.clientSticky.addAll(incoming.clientSticky)) : existing.sticky.addAll(incoming.sticky))
.removeWhere( .removeWhere(
(rowId) => incoming.timeline.values.contains(rowId), (rowId) => incoming.timeline.values.contains(rowId),
), ),

View file

@ -9,14 +9,14 @@ class SettingsController extends AsyncNotifier<Settings> {
final file = await ref.watch(SettingsFileController.provider.future); final file = await ref.watch(SettingsFileController.provider.future);
try { try {
return .fromJson(json.decode(await file.readAsString())); return Settings.fromJson(json.decode(await file.readAsString()));
} catch (_) { } catch (_) {
return .new(); return Settings();
} }
} }
Future<void> set(Settings settings) async { Future<void> set(Settings settings) async {
state = .data(settings); state = AsyncData(settings);
final file = await ref.watch(SettingsFileController.provider.future); final file = await ref.watch(SettingsFileController.provider.future);
await file.writeAsString(json.encode(settings.toJson())); await file.writeAsString(json.encode(settings.toJson()));
} }

View file

@ -1,19 +1,13 @@
import "dart:io"; import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.dart"; import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart";
import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -24,6 +18,9 @@ class SettingsSectionsController
@override @override
Future<IMap<String, IList<SettingsCategory>>> build() async { Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future); final settings = await ref.watch(SettingsController.provider.future);
final specVersionsResponse = await ref
.watch(ClientController.provider.notifier)
.getSpecVersions();
return .new({ return .new({
"General": .new([ "General": .new([
@ -52,7 +49,8 @@ class SettingsSectionsController
.new( .new(
title: "Use Dynamic Theme", title: "Use Dynamic Theme",
icon: Icons.palette, icon: Icons.palette,
description: "Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.", description:
"Toggle on or off Dynamic Theme. Only available on Android, Linux, Windows, or MacOS.",
builder: (title, description, icon) => SwitchListTile( builder: (title, description, icon) => SwitchListTile(
title: Text(title), title: Text(title),
subtitle: Text(description), subtitle: Text(description),
@ -78,7 +76,8 @@ class SettingsSectionsController
settings: .new([ settings: .new([
.new( .new(
title: "Linux Mobile Mode", title: "Linux Mobile Mode",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.", description:
"Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
icon: Icons.construction, icon: Icons.construction,
builder: (title, description, icon) => SwitchListTile( builder: (title, description, icon) => SwitchListTile(
title: Text(title), title: Text(title),
@ -99,103 +98,17 @@ class SettingsSectionsController
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
"Account": .new([ "Account": .new([
.new(title: "Profile", icon: Icons.person, settings: .new([])), .new(title: "Profile", icon: Icons.person, settings: .new([])),
.new(
title: "Notifications",
icon: Icons.notifications,
settings: .new([
.new(
title: "Push notifications",
description: "Enable push notifications using Web Push",
builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) {
final loading = useState(false);
final pusherRegistered = ref.watch(
UnifiedPushController.provider,
);
final unifiedPushAllowed = ref.watch(
UnifiedPushAllowedController.provider,
);
return SwitchListTile(
title: Text(title),
subtitle: Text(
unifiedPushAllowed.maybeWhen(
data: (data) => data,
orElse: () => null,
) ??
description,
),
secondary: Icon(icon),
value: pusherRegistered.maybeWhen(
data: (value) => value,
orElse: () => false,
),
onChanged: loading.value
? null
: unifiedPushAllowed.maybeWhen(
data: (data) => data == null,
orElse: () => false,
)
? pusherRegistered.maybeWhen(
data: (_) => (value) async {
try {
loading.value = true;
if (value) {
if (await ref
.watch(
NotificationController
.provider
.notifier,
)
.requestPermissions()) {
await ref
.watch(
UnifiedPushController
.provider
.notifier,
)
.register();
} else {
// TODO: Handle not granted
}
} else {
await ref
.watch(
UnifiedPushController
.provider
.notifier,
)
.deregister();
}
} catch (error, stackTrace) {
showError(error, stackTrace);
} finally {
loading.value = false;
}
},
orElse: () => null,
)
: null,
);
},
),
icon: Icons.notification_add,
),
]),
),
.new( .new(
title: "Safety", title: "Safety",
icon: Icons.gpp_good, icon: Icons.gpp_good,
settings: .new([ settings: .new([
.new( .new(
title: "Invite Blocking", title: "Invite Blocking",
description: "Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).", description:
"Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).",
builder: (title, description, icon) => Consumer( builder: (title, description, icon) => Consumer(
builder: (context, ref, _) { builder: (context, ref, _) =>
final specVersionsResponse = ref.watch( DialogListTile<DefaultInviteAction>(
SpecVersionsController.provider,
);
return DialogListTile<DefaultInviteAction>(
icon: Icon(icon), icon: Icon(icon),
title: title, title: title,
subtitle: Text(description), subtitle: Text(description),
@ -203,23 +116,17 @@ class SettingsSectionsController
.watch(AccountDataController.provider) .watch(AccountDataController.provider)
.invitePermissionConfig .invitePermissionConfig
.defaultAction, .defaultAction,
options: options: specVersionsResponse.unstableFeatures.msc4494
specVersionsResponse.maybeWhen(
data: (data) => data.unstableFeatures.msc4494,
orElse: () => true,
)
? DefaultInviteAction.values ? DefaultInviteAction.values
: IList(DefaultInviteAction.values) : IList(
.remove(.denyPublic) DefaultInviteAction.values,
.toList(), ).remove(.denyPublic).toList(),
getName: (option) => switch (option) { getName: (option) => switch (option) {
.allow => "Allow", .allow => "Allow",
.deny => "Deny", .deny => "Deny",
.denyPublic => "Deny public", .denyPublic => "Deny public",
}, },
onChanged: specVersionsResponse.maybeWhen( onChanged: (value) => ref
data: (_) =>
(value) => ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.setAccountData( .setAccountData(
.new( .new(
@ -230,10 +137,7 @@ class SettingsSectionsController
), ),
) )
.onError(showError), .onError(showError),
orElse: () => null,
), ),
);
},
), ),
icon: Icons.person_off, icon: Icons.person_off,
), ),
@ -252,14 +156,15 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon( return M3EButton.icon(
onPressed: () async { onPressed: () async {
await ref Navigator.of(
.watch(UnifiedPushController.provider.notifier) context,
.deregister() ).popUntil((route) => route.isFirst);
.onError(showError);
await WidgetsBinding.instance.endOfFrame;
await ref await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.logout() .logout();
.onError(showError);
}, },
label: Text(title), label: Text(title),
icon: Icon(icon), icon: Icon(icon),

View file

@ -1,12 +1,12 @@
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:shared_preferences/shared_preferences.dart"; import "package:shared_preferences/shared_preferences.dart";
class SharedPrefsController extends Notifier<SharedPreferencesAsync> { class SharedPrefsController extends AsyncNotifier<SharedPreferences> {
@override @override
SharedPreferencesAsync build() => SharedPreferencesAsync(); Future<SharedPreferences> build() async => .getInstance();
static final provider = static final provider =
NotifierProvider<SharedPrefsController, SharedPreferencesAsync>( AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(
SharedPrefsController.new, SharedPrefsController.new,
); );
} }

View file

@ -1,6 +1,6 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";

View file

@ -1,15 +0,0 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/spec_versions_response.dart";
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
@override
Future<SpecVersionsResponse> build() =>
ref.read(ClientController.provider.notifier).getSpecVersions();
static final provider =
AsyncNotifierProvider.autoDispose<
SpecVersionsController,
SpecVersionsResponse
>(SpecVersionsController.new);
}

View file

@ -1,178 +0,0 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
import "package:unifiedpush/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart";
class UnifiedPushController extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return false;
final registered = await UnifiedPush.initialize(
linuxOptions: .new(
dbusName: "nexus.federated.nexus.UnifiedPush",
storage: UnifiedPushStorageSharedPreferences(),
background: isInBackground,
shouldWriteService: false,
),
onNewEndpoint: (endpoint, instance) async {
final pushKey = endpoint.pubKeySet!.pubKey;
await ref
.read(PushKeyController.provider(instance).notifier)
.set(pushKey);
await ref
.read(ClientController.provider.notifier)
.registerPusher(
.new(
appDisplayName: "Nexus",
appId: "nexus.federated.nexus",
data: .webPush(
url: .parse(endpoint.url),
auth: endpoint.pubKeySet!.auth,
),
deviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
kind: .webPush,
lang: "en",
pushKey: pushKey,
),
);
state = .data(true);
},
onMessage: (message, instance) async {
debugPrint("UP message received for $instance");
if (message.decrypted == false) {
throw Exception(
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
);
}
final (event, roomMetadata) = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
if (event.unreadType?.shouldNotify() != true ||
(!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) &&
await ref.read(
KeyController.provider(KeyController.roomKey).future,
) ==
event.roomId)) {
if (isInBackground) exit(0);
return;
}
final icon = roomMetadata.avatar == null
? null
: await ref
.read(ClientController.provider.notifier)
.downloadMedia(
.new(mxc: roomMetadata.avatar!, isAvatar: true),
);
await ref
.read(NotificationController.provider.notifier)
.send(
id: event.eventId.hashCode & 0x7fffffff,
title: roomMetadata.name ?? "New Event",
icon: icon,
payload: event.eventId,
body: switch (event.content) {
MessageContent(:final body?) ||
StickerContent(:final body) => body,
_ => null,
},
);
if (isInBackground) exit(0);
},
onUnregistered: deregister,
);
ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
(_, _) => register(),
);
if (registered) {
// Needs to be registered every startup
await register();
}
return registered;
}
Future<void> register() async {
state = .loading();
try {
final deviceId = ref.read(
ClientStateController.provider.select((value) => value?.deviceId),
);
if (deviceId == null) return;
final capabilities = await ref
.read(ClientController.provider.notifier)
.getCapabilities();
if (capabilities.webpush?.vapid == null) {
throw UnsupportedError(
"Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
);
}
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
throw Exception("No UnifiedPush distributors found.");
}
await UnifiedPush.register(
instance: deviceId,
vapid: capabilities.webpush?.vapid,
);
} catch (_) {
state = .data(false);
rethrow;
}
}
Future<void> deregister([String? instance]) async {
final clientState = ref.read(ClientStateController.provider);
final keyProvider = PushKeyController.provider(
instance ?? clientState!.deviceId!,
);
final key = await ref.read(keyProvider.future);
if (key != null) {
await ref
.read(ClientController.provider.notifier)
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
await ref.read(keyProvider.notifier).set(null);
} else {
debugPrint(
"No matching pushKey found. Skipping deregistration from homeserver.",
);
}
await UnifiedPush.unregister(instance ?? clientState!.deviceId!);
state = .data(false);
}
static final provider = AsyncNotifierProvider<UnifiedPushController, bool>(
UnifiedPushController.new,
);
}

View file

@ -1,33 +0,0 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:unifiedpush/unifiedpush.dart";
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
return "No valid distributors found. ${Platform.isLinux
? "Try installing KUnifiedPush"
: Platform.isAndroid
? "Try installing Google Play Services or NTFY"
: "Your platform is not currently supported by UnifiedPush"}.";
}
final capabilities = await ref
.watch(ClientController.provider.notifier)
.getCapabilities();
if (capabilities.webpush?.vapid == null) {
return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.";
}
return null;
}
static final provider =
AsyncNotifierProvider.autoDispose<UnifiedPushAllowedController, String?>(
UnifiedPushAllowedController.new,
);
}

View file

@ -3,8 +3,10 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/open_graph_data.dart";
class UrlPreviewController(final Uri url) class UrlPreviewController extends AsyncNotifier<OpenGraphData?> {
extends AsyncNotifier<OpenGraphData?> { final Uri url;
UrlPreviewController(this.url);
@override @override
Future<OpenGraphData?> build() async { Future<OpenGraphData?> build() async {
if (url.host == "matrix.to") return null; if (url.host == "matrix.to") return null;

View file

@ -7,7 +7,10 @@ import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class ViaController(final Room room) extends Notifier<String> { class ViaController extends Notifier<String> {
final Room room;
ViaController(this.room);
@override @override
String build() { String build() {
final servers = <String>{}; final servers = <String>{};

View file

@ -1,266 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/main.dart";
extension BuildEventOptions on Event {
IList<PopupMenuEntry> buildEventOptions({
required BuildContext context,
required WidgetRef ref,
required String roomId,
required String userId,
required void Function(Event, RelationType) onRelation,
}) {
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
final notifier = ref.read(
RoomChatController.provider((roomId, null)).notifier,
);
final client = ref.read(ClientController.provider.notifier);
final isPinned = ref
.watch(PinnedIdsController.provider(roomId))
.contains(eventId);
Future<void> sendReaction(String emoji) async {
await notifier.sendReaction(emoji, this).onError(showError);
await ref
.read(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
}
void showReasonDialog({
required String title,
required String description,
required String action,
required Future<void> Function(String reason) onConfirm,
}) {
showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (context) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(description),
const SizedBox(height: 12),
TextField(
controller: reasonController,
textCapitalization: .sentences,
decoration: .new(labelText: "Reason (optional)"),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
final reason = reasonController.text;
Navigator.of(context).pop();
onConfirm(reason).onError(showError);
},
child: Text(action),
),
],
);
},
),
);
}
return .new([
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .reaction, roomId: roomId),
),
))
PopupMenuItem(
enabled: false,
child: IconTheme(
data: theme.iconTheme,
child: Row(
children: [
for (final emoji in {
...ref
.watch(RecentEmojiController.provider)
.map((entry) => entry.emoji),
"👍",
"🤣",
"😭",
"🤔",
}.take(4))
IconButton(
icon: Text(emoji),
onPressed: () {
Navigator.of(context).pop();
sendReaction(emoji);
},
),
IconButton(
icon: const Icon(Icons.emoji_emotions),
onPressed: () {
Navigator.of(context).pop();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
allowFreeText: true,
onSelection: (emoji) {
Navigator.of(context).pop();
sendReaction(emoji);
},
),
);
},
),
],
),
),
),
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => onRelation(this, .reply),
child: const ListTile(
leading: Icon(Icons.reply),
title: Text("Reply"),
),
),
if (content is MessageContent && sender == userId)
PopupMenuItem(
onTap: () => onRelation(this, .edit),
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
if (ref.watch(
PowerLevelController.provider(
.state(eventType: .pinnedEvents, roomId: roomId),
),
))
PopupMenuItem(
onTap: () async {
try {
final pins = ref.read(
PinnedIdsController.provider(roomId).notifier,
);
if (isPinned) {
await pins.removePin(eventId);
} else {
await pins.addPin(eventId);
}
} catch (error, stackTrace) {
showError(error, stackTrace);
}
},
child: ListTile(
leading: const Icon(Icons.push_pin),
title: Text(isPinned ? "Unpin Event" : "Pin Event"),
),
),
PopupMenuItem(
onTap: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return;
final vias = ref.read(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/$eventId$vias",
),
);
},
child: const ListTile(
leading: Icon(Icons.link),
title: Text("Copy Link"),
),
),
if (content case MessageContent(:final body?))
PopupMenuItem(
onTap: () => Clipboard.setData(ClipboardData(text: body)),
child: const ListTile(
leading: Icon(Icons.copy),
title: Text("Copy Text"),
),
),
if (ref.watch(
PowerLevelController.provider(
.redaction(targetUser: sender, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => showReasonDialog(
title: "Delete Message",
description:
"Are you sure you want to delete this message? "
"This cannot be reversed.",
action: "Delete",
onConfirm: (reason) => notifier.deleteMessage(this, reason: reason),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: .new(color: danger)),
),
),
PopupMenuItem(
onTap: () => showReasonDialog(
title: "Report",
description:
"Report this this to your server administrators, "
"who can take action like banning this server or room.",
action: "Report",
onConfirm: (reason) => client.reportEvent(
.new(
roomId: roomId,
eventId: eventId,
reason: reason.isEmpty ? null : reason,
),
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: .new(color: danger)),
),
),
]);
}
}

View file

@ -9,7 +9,6 @@ extension LinkToMention on String {
/// ///
/// Returns the decoded identifier (e.g. "#room:matrix.org") /// Returns the decoded identifier (e.g. "#room:matrix.org")
/// or null if this is not a Matrix link. /// or null if this is not a Matrix link.
/// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code.
String? get mention { String? get mention {
final trimmed = trim(); final trimmed = trim();

View file

@ -1,4 +1,4 @@
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
extension SchemeToTheme on ColorScheme { extension SchemeToTheme on ColorScheme {
ThemeData get theme { ThemeData get theme {

View file

@ -1,11 +1,11 @@
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
import "package:flutter_svg/flutter_svg.dart"; import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_card_list/m3e_card_list.dart"; import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:package_info_plus/package_info_plus.dart"; import "package:package_info_plus/package_info_plus.dart";
extension ShowAboutDialog on BuildContext { extension ShowContextMenu on BuildContext {
Future<void> showAboutDialog(WidgetRef ref) async { Future<void> showAboutDialog(WidgetRef ref) async {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
@ -20,7 +20,7 @@ extension ShowAboutDialog on BuildContext {
Row( Row(
spacing: 12, spacing: 12,
children: [ children: [
SvgPicture.asset("assets/bundled/icon.svg", width: 64), SvgPicture.asset("assets/icon.svg", width: 64),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: .start, crossAxisAlignment: .start,
@ -50,8 +50,8 @@ extension ShowAboutDialog on BuildContext {
M3ECardColumn( M3ECardColumn(
onTap: (index) => onTap: (index) =>
ref.watch(LaunchHelper.provider).launchUrl(switch (index) { ref.watch(LaunchHelper.provider).launchUrl(switch (index) {
0 => .https("git.federated.nexus", "nexus/nexus"), 0 => Uri.https("git.federated.nexus", "nexus/nexus"),
_ => .https("liberapay.com", "QuadRadical"), _ => Uri.https("liberapay.com", "QuadRadical"),
}), }),
children: [ children: [
ListTile( ListTile(

View file

@ -1,4 +1,4 @@
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
extension ShowContextMenu on BuildContext { extension ShowContextMenu on BuildContext {
void showContextMenu({ void showContextMenu({
@ -9,7 +9,7 @@ extension ShowContextMenu on BuildContext {
showMenu( showMenu(
context: this, context: this,
constraints: .loose(.infinite), constraints: .loose(Size.infinite),
position: .fromLTRB( position: .fromLTRB(
globalPosition.dx, globalPosition.dx,
globalPosition.dy, globalPosition.dy,

View file

@ -1,4 +1,4 @@
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";

View file

@ -1,5 +1,5 @@
import "package:color_hash/color_hash.dart"; import "package:color_hash/color_hash.dart";
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
extension ToColor on String { extension ToColor on String {
Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color; Color get colorHash => ColorHash(this, lightness: .5, saturation: .7).color;

View file

@ -1,203 +0,0 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_chat.dart";
final class ChatScroll({
required final IList<Event> historyItems,
required final IList<Event> liveItems,
required final GlobalKey centerKey,
required final ScrollController scrollController,
required final bool atBottom,
required final Future<void> Function(String id) jumpToId,
required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(String eventId) keyFor,
}) {
factory use({
required AsyncValue<RoomChat?> controllerData,
required Future<void> Function(Direction direction) paginate,
required Future<void> Function() markRead,
required ValueNotifier<String?> contextualEvent,
}) {
final anchorId = useState<String?>(null);
final itemKeys = useMemoized(() => <String, GlobalKey>{}, []);
GlobalKey keyFor(String eventId) =>
itemKeys.putIfAbsent(eventId, GlobalKey.new);
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final atBottom = useState(true);
final pendingAnchorTarget = useState<String?>(null);
final anchorMountedCompleter = useRef<Completer<BuildContext>?>(null);
useEffect(() {
if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) {
final hasContextualEvent = value.timeline.any(
(event) => event.eventId == contextualEvent.value,
);
anchorId.value = hasContextualEvent
? contextualEvent.value
: value.timeline.last.eventId;
}
}
return null;
}, [controllerData, contextualEvent.value]);
useEffect(() {
final target = pendingAnchorTarget.value;
if (target == null) return null;
final found =
controllerData.value?.timeline.any(
(event) => event.eventId == target,
) ??
false;
if (found || controllerData is AsyncError) {
if (found) {
anchorId.value = target;
WidgetsBinding.instance.addPostFrameCallback((_) {
final context = keyFor(target).currentContext;
if (context != null && context.mounted) {
anchorMountedCompleter.value?.complete(context);
anchorMountedCompleter.value = null;
}
});
} else {
anchorMountedCompleter.value?.completeError(
StateError("Failed to load context for $target"),
);
anchorMountedCompleter.value = null;
}
pendingAnchorTarget.value = null;
}
return null;
}, [controllerData, pendingAnchorTarget.value]);
final ({IList<Event> history, IList<Event> live}) split = useMemoized(() {
final items = controllerData.value?.timeline;
final anchor = anchorId.value;
if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty());
}
final anchorIndex = items.indexWhere((item) => item.eventId == anchor);
if (anchorIndex == -1) {
return (history: const .empty(), live: items);
}
return (
history: items.take(anchorIndex).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(),
);
}, [controllerData, anchorId.value]);
useEffect(
() {
const loadThreshold = 500.0;
const readThreshold = 50.0;
Future<void> checkPosition() async {
if (!scrollController.hasClients) return;
final position = scrollController.position;
final isAtBottom = position.extentBefore <= readThreshold;
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
if (position.extentAfter <= loadThreshold) {
await paginate(.backward);
} else if (contextualEvent.value != null &&
position.extentBefore <= loadThreshold) {
await paginate(.forward);
} else if (position.extentBefore <= readThreshold) {
await markRead();
}
}
scrollController.addListener(checkPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
},
[
scrollController,
controllerData,
paginate,
markRead,
contextualEvent.value,
],
);
return .new(
historyItems: split.history,
liveItems: split.live,
centerKey: centerKey,
scrollController: scrollController,
atBottom: atBottom.value,
jumpToId: (String itemId) async {
if (!scrollController.hasClients) return;
final existing = keyFor(itemId).currentContext;
if (existing != null && existing.mounted) {
// Already mounted, just scroll
await Scrollable.ensureVisible(
existing,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
} else {
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = itemId;
contextualEvent.value = itemId;
final context = await completer.future;
if (!context.mounted) return;
await Scrollable.ensureVisible(context, alignment: 10);
if (!context.mounted) return;
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeOutCirc,
);
}
},
jumpToBottom: () async {
if (contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
if (!scrollController.hasClients) return;
await scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
},
keyFor: keyFor,
);
}
}

View file

@ -2,7 +2,10 @@ import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:url_launcher/url_launcher.dart" as ul; import "package:url_launcher/url_launcher.dart" as ul;
class LaunchHelper(Ref ref) { class LaunchHelper {
final Ref ref;
LaunchHelper(this.ref);
Future<bool> launchUrl(Uri url, {bool useWebview = false}) async { Future<bool> launchUrl(Uri url, {bool useWebview = false}) async {
try { try {
return await ul.launchUrl( return await ul.launchUrl(

View file

@ -1,12 +1,14 @@
import "dart:ui"; import "dart:ui";
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
class MxcImage(final WidgetRef ref, final DownloadMediaRequest request) class MxcImage extends ImageProvider<MxcImage> {
extends ImageProvider<MxcImage> { final WidgetRef ref;
final DownloadMediaRequest request;
const MxcImage(this.ref, this.request);
@override @override
Future<MxcImage> obtainKey(ImageConfiguration configuration) => Future<MxcImage> obtainKey(ImageConfiguration configuration) =>
Future.value(this); Future.value(this);

View file

@ -1,31 +1,26 @@
import "dart:io"; import "dart:io";
import "package:dynamic_color/dynamic_color.dart"; import "package:dynamic_color/dynamic_color.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:media_kit/media_kit.dart"; import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/gomuks_listener.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/multi_provider.dart"; import "package:nexus/controllers/multi_provider.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/shared_prefs.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/helpers/font_licenses.dart"; import "package:nexus/helpers/font_licenses.dart";
import "package:nexus/widgets/pages/chat.dart"; import "package:nexus/pages/chat.dart";
import "package:nexus/widgets/pages/select_server.dart"; import "package:nexus/pages/select_server.dart";
import "package:nexus/widgets/pages/settings.dart"; import "package:nexus/pages/verify.dart";
import "package:nexus/widgets/pages/verify.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
import "package:material_ui/material_ui.dart"; import "package:flutter/material.dart";
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
late final bool isInBackground;
final class Logger extends ProviderObserver { final class Logger extends ProviderObserver {
@override @override
@ -42,7 +37,14 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
} }
void showError(Object error, [StackTrace? stackTrace]) { void showError(Object error, [StackTrace? stackTrace]) {
if (error.toString().contains("'_nextFrame != null': is not true.")) { if (error.toString().contains("DioException") ||
error.toString().contains(
"setState() or markNeedsBuild() called during build.",
) ||
error.toString().contains("Invalid source") ||
error.toString().contains("UTF-16") ||
error.toString().contains("HTTP request failed") ||
error.toString().contains("Invalid image data")) {
return; return;
} }
@ -59,27 +61,10 @@ void showError(Object error, [StackTrace? stackTrace]) {
} }
} }
void main(List<String> args) async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack);
isInBackground =
Platform.environment["FLUTTER_HEADLESS"] != null ||
args.contains("--unifiedpush-bg");
if (isInBackground) {
await ProviderContainer()
.read(UnifiedPushController.provider.future)
.timeout(Duration(seconds: 5));
await Future.delayed(Duration(seconds: 10));
exit(0);
} else {
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow( await windowManager.waitUntilReadyToShow(
@ -91,6 +76,11 @@ void main(List<String> args) async {
await windowManager.setMinimumSize(Size.square(500)); await windowManager.setMinimumSize(Size.square(500));
} }
LicenseRegistry.addLicense(() => Stream.fromIterable(fontLicenses));
FlutterError.onError = (FlutterErrorDetails details) =>
showError(details.exception.toString(), details.stack);
runApp( runApp(
ProviderScope( ProviderScope(
retry: (_, _) => null, retry: (_, _) => null,
@ -99,19 +89,21 @@ void main(List<String> args) async {
// ignore: dead_code // ignore: dead_code
if (false && kDebugMode) Logger(), if (false && kDebugMode) Logger(),
], ],
child: App(), child: const App(),
), ),
); );
}
} }
class const App({super.key}) extends StatelessWidget { class App extends StatelessWidget {
const App({super.key});
@override @override
Widget build(BuildContext context) => DynamicColorBuilder( Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => Consumer( builder: (lightDynamic, darkDynamic) => Consumer(
builder: (context, ref, child) => MaterialApp( builder: (context, ref, child) => MaterialApp(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// Use indigo to work around bugs in theme generation
theme: theme:
(ref (ref
.watch(SettingsController.provider) .watch(SettingsController.provider)
@ -142,21 +134,21 @@ class const App({super.key}) extends StatelessWidget {
), ),
child: Scaffold( child: Scaffold(
body: Consumer( body: Consumer(
builder: (_, ref, _) => switch (ref.watch( builder: (_, ref, _) => ref
.watch(
MultiProviderController.provider( MultiProviderController.provider(
.new([ IListConst([
GomuksListenerController.provider, SharedPrefsController.provider,
NotificationController.provider, ClientController.provider,
UnifiedPushController.provider,
MemberListOpenedController.provider,
KeyController.provider(KeyController.roomKey),
KeyController.provider(KeyController.spaceKey),
]), ]),
), ),
)) { )
AsyncData(value: _) || AsyncLoading(value: _?) => Consumer( .betterWhen(
data: (_) => Consumer(
builder: (_, ref, _) { builder: (_, ref, _) {
final clientState = ref.watch(ClientStateController.provider); final clientState = ref.watch(
ClientStateController.provider,
);
if (clientState == null || !clientState.isInitialized) { if (clientState == null || !clientState.isInitialized) {
return Loading(); return Loading();
@ -171,26 +163,7 @@ class const App({super.key}) extends StatelessWidget {
} }
}, },
), ),
AsyncLoading _ => Scaffold(
appBar: Appbar(
actions: .new([
IconButton(
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
), ),
icon: Icon(Icons.settings),
),
]),
),
body: Loading(),
),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
), ),
), ),
), ),

View file

@ -1,60 +1,62 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
part "account_data.freezed.dart"; part "account_data.freezed.dart";
part "account_data.g.dart"; part "account_data.g.dart";
@Freezed(toJson: false, fromJson: false) @freezed
@JsonSerializable() abstract class AccountData with _$AccountData {
class const AccountData({ const AccountData._();
@JsonKey(name: AccountData.invitePermissionConfigKey) static List<dynamic>? readRecentEmojiValue(
final InvitePermissionConfig invitePermissionConfig = Map<dynamic, dynamic> json,
const InvitePermissionConfig(), String key,
) => json[key]?["recent_emoji"];
@JsonKey(name: AccountData.directKey) static Map<String, List<dynamic>>? recentEmojiToJson(
final IMap<String, IList<String>> directMessages = const IMap.empty(), IList<RecentEmoji> recentEmoji,
) => {"recent_emoji": recentEmoji.map((emoji) => emoji.toJson()).toList()};
@JsonKey(name: AccountData.recentEmojiKey)
final RecentEmojiData recentEmoji = const RecentEmojiData(),
}) with _$AccountData {
static const invitePermissionConfigKey = "m.invite_permission_config"; static const invitePermissionConfigKey = "m.invite_permission_config";
static const directKey = "m.direct"; static const directKey = "m.direct";
static const recentEmojiKey = "m.recent_emoji"; static const recentEmojiKey = "m.recent_emoji";
Map<String, Object?> toJson() => _$AccountDataToJson(this); const factory AccountData({
@JsonKey(name: AccountData.invitePermissionConfigKey)
@Default(InvitePermissionConfig())
InvitePermissionConfig invitePermissionConfig,
@JsonKey(name: AccountData.directKey)
@Default(IMap.empty())
IMap<String, IList<String>> directMessages,
@JsonKey(
name: AccountData.recentEmojiKey,
readValue: AccountData.readRecentEmojiValue,
toJson: AccountData.recentEmojiToJson,
)
@Default(IList.empty())
IList<RecentEmoji> recentEmoji,
}) = _AccountData;
factory AccountData.fromJson(Map<String, Object?> json) => factory AccountData.fromJson(Map<String, Object?> json) =>
_$AccountDataFromJson(json); _$AccountDataFromJson(json);
} }
@Freezed(toJson: false, fromJson: false) @freezed
@JsonSerializable() abstract class InvitePermissionConfig with _$InvitePermissionConfig {
class const RecentEmojiData({ const factory InvitePermissionConfig({
final IList<RecentEmoji> recentEmoji = const IList.empty(),
}) with _$RecentEmojiData {
Map<String, Object?> toJson() => _$RecentEmojiDataToJson(this);
factory RecentEmojiData.fromJson(Map<String, Object?> json) =>
_$RecentEmojiDataFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const InvitePermissionConfig({
@JsonKey(unknownEnumValue: DefaultInviteAction.allow) @JsonKey(unknownEnumValue: DefaultInviteAction.allow)
final DefaultInviteAction defaultAction = DefaultInviteAction.allow, @Default(DefaultInviteAction.allow)
}) with _$InvitePermissionConfig { DefaultInviteAction defaultAction,
Map<String, Object?> toJson() => _$InvitePermissionConfigToJson(this); }) = _InvitePermissionConfig;
factory InvitePermissionConfig.fromJson(Map<String, Object?> json) => factory InvitePermissionConfig.fromJson(Map<String, Object?> json) =>
_$InvitePermissionConfigFromJson(json); _$InvitePermissionConfigFromJson(json);
} }
@Freezed(toJson: false, fromJson: false) @freezed
@JsonSerializable() abstract class RecentEmoji with _$RecentEmoji {
class const RecentEmoji({required final String emoji, required final int total}) const factory RecentEmoji({required String emoji, required int total}) =
with _$RecentEmoji { _RecentEmoji;
Map<String, Object?> toJson() => _$RecentEmojiToJson(this);
factory RecentEmoji.fromJson(Map<String, Object?> json) => factory RecentEmoji.fromJson(Map<String, Object?> json) =>
_$RecentEmojiFromJson(json); _$RecentEmojiFromJson(json);

View file

@ -1,25 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "capabilities.freezed.dart";
part "capabilities.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const Capabilities({
@JsonKey(name: "org.matrix.msc4174.webpush") final WebPush? webpush,
}) with _$Capabilities {
Map<String, Object?> toJson() => _$CapabilitiesToJson(this);
factory Capabilities.fromJson(Map<String, Object?> json) =>
_$CapabilitiesFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const WebPush({required final bool enabled, final String? vapid})
with _$WebPush {
Map<String, Object?> toJson() => _$WebPushToJson(this);
factory WebPush.fromJson(Map<String, Object?> json) =>
_$WebPushFromJson(json);
}

View file

@ -1,19 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
part "client_state.freezed.dart"; part "client_state.freezed.dart";
part "client_state.g.dart"; part "client_state.g.dart";
@Freezed(toJson: false, fromJson: false) @freezed
@JsonSerializable() abstract class ClientState with _$ClientState {
class const ClientState({ const factory ClientState({
required final bool isInitialized, required bool isInitialized,
required final bool isLoggedIn, required bool isLoggedIn,
required final bool isVerified, required bool isVerified,
required final String? userId, required String? userId,
required final String? deviceId, required String? homeserverUrl,
required final String? homeserverUrl, }) = _ClientState;
}) with _$ClientState {
Map<String, Object?> toJson() => _$ClientStateToJson(this);
factory ClientState.fromJson(Map<String, Object?> json) => factory ClientState.fromJson(Map<String, Object?> json) =>
_$ClientStateFromJson(json); _$ClientStateFromJson(json);

View file

@ -1,16 +1,14 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/membership_status.dart"; import "package:nexus/models/membership_status.dart";
part "members_by_status.freezed.dart"; part "members_by_status.freezed.dart";
part "members_by_status.g.dart"; part "members_by_status.g.dart";
@Freezed(toJson: false, fromJson: false) @freezed
@JsonSerializable() abstract class MembersByStatusConfig with _$MembersByStatusConfig {
class const MembersByStatusConfig({ const factory MembersByStatusConfig({
required final String roomId, required String roomId,
required final MembershipStatus status, required MembershipStatus status,
}) with _$MembersByStatusConfig { }) = _MembersByStatusConfig;
Map<String, Object?> toJson() => _$MembersByStatusConfigToJson(this);
factory MembersByStatusConfig.fromJson(Map<String, Object?> json) => factory MembersByStatusConfig.fromJson(Map<String, Object?> json) =>
_$MembersByStatusConfigFromJson(json); _$MembersByStatusConfigFromJson(json);

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