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 }}
- 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:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}

View file

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

View file

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

View file

@ -1,64 +1,65 @@
name: "Build EXE"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-exe:
runs-on: windows-latest
build-exe:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: gomuks/go.mod
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.44.4
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
mingw-w64-x86_64-gcc
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: gomuks/go.mod
- name: Go build
run: |
cd gomuks/pkg/ffi
go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
mingw-w64-x86_64-gcc
- name: Build with Flutter
run: |
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build windows --release
- name: Go build
run: |
cd gomuks/pkg/ffi
go build -tags goolm,sqlite_fts5 -o ../../../libgomuks.dll -buildmode=c-shared
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
run: |
cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
- name: Build with Flutter
run: |
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build windows --release
- name: Install Inno Setup
run: choco install innosetup -y
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
run: |
cp /mingw64/bin/libgcc_s_seh-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
- name: Build Inno Setup installer
run: iscc windows/installer.iss
- name: Install Inno Setup
run: choco install innosetup -y
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: windows-installer
path: windows/dist/Nexus-Setup.exe
- name: Build Inno Setup installer
run: iscc windows/installer.iss
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: windows-installer
path: windows/dist/Nexus-Setup.exe

1
.gitmodules vendored
View file

@ -1,3 +1,4 @@
[submodule "gomuks"]
path = 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",
"prefs",
"unban",
"unifiedpush",
"unredact",
"webpush"
"unredact"
]
}

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:
```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.

View file

@ -17,15 +17,10 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Platform Support
- [x] Linux
- [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).
- [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).
- [x] Android
- [x] MacOS
- [x] Unsigned .ipa
- [ ] App Store
- [x] iOS
- [x] Unsigned .ipa
- [ ] App Store
- [x] MacOS - Unsigned .App only
- [x] iOS - Unsigned .ipa only
- [ ] Web (may not be possible)
- [x] Login (via OAuth)
- [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] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Tags
- [x] Mentions
- [x] Users
- [x] Rooms
- [x] Emojis
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
- [ ] Custom emojis/stickers
- [ ] GIFs using Gomuks' GIF proxies
- [x] Receiving
@ -68,7 +63,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] URL Previews
- [x] Replies
- [x] Viewing
- [x] Jump to original message
- [ ] Jump to original message
- [x] In loaded timeline
- [ ] Out of loaded timeline
- [x] Edits
- [x] Attachments
- [x] Unencrypted
@ -108,8 +105,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Member list
- [x] Sort by power level
- [ ] Colors based off of power level
- [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [x] Notifications page
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites
- [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:
- [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 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)
- Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
- [NixOS Module](linux/nix/module.nix)
## 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"
> ```
Build webcrypto:
```sh
flutter pub run webcrypto:setup
```
Build generated files, and watch for new changes:
```sh

View file

@ -1,18 +1,12 @@
analyzer:
errors:
invalid_annotation_target: ignore
avoid_print: ignore
exclude:
- "build/**"
- "**/*.g.dart"
- "**/*.freezed.dart"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors:
invalid_annotation_target: ignore
avoid_print: ignore
exclude:
- "build/**"
- "**/*.g.dart"
- "**/*.freezed.dart"
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_double_quotes: true
rules:
prefer_double_quotes: true

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

View file

@ -5,7 +5,7 @@
self.submodules = true;
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
nix2flatpak.url = "github:Henry-Hiles/nix2flatpak/quad/nexus";
nix2flatpak.url = "github:neobrain/nix2flatpak";
};
outputs =
@ -16,12 +16,62 @@
...
}@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ ./linux/nix ];
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-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 "package:collection/collection.dart";
import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart";
@ -51,10 +50,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
break;
case OS.macOS:
libFileName = "libgomuks.dylib";
extraEnv = {
"SDKROOT": await getXCodeTool(),
"MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(),
};
extraEnv = {"SDKROOT": await getXCodeTool()};
break;
case OS.windows:
libFileName = "libgomuks.dll";
@ -135,8 +131,6 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"go",
[
"build",
"-trimpath",
"-ldflags=-s -w",
"-tags",
tags,
"-o",

View file

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

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:path/path.dart";
class AttachmentController(final String roomId)
extends Notifier<(String, MessageContent?)?> {
class AttachmentController extends Notifier<(String, MessageContent?)?> {
final String roomId;
AttachmentController(this.roomId);
@override
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/requests/oauth/get_auth_url.dart";
class AuthUrlController(final Uri homeserver)
extends AsyncNotifier<OAuthAuthCodeResponse> {
class AuthUrlController extends AsyncNotifier<OAuthAuthCodeResponse> {
final Uri homeserver;
AuthUrlController(this.homeserver);
@override
Future<OAuthAuthCodeResponse> build() async => ref
.watch(ClientController.provider.notifier)

View file

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

View file

@ -1,28 +1,26 @@
import "dart:async";
import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/cupertino.dart";
import "package:intl/intl.dart";
import "package:flutter/foundation.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/models/capabilities.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/message.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/open_graph_data.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/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_room_state.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/register_client.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/register_pusher.dart";
import "package:nexus/models/requests/report.dart";
import "package:nexus/models/requests/send_event.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/upload_media.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/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart";
@ -52,99 +47,134 @@ import "package:path_provider/path_provider.dart";
class ClientController extends AsyncNotifier<int> {
@override
Future<int> build() async {
debugPrint("Setting Gomuks env...");
final Pointer<Char> root;
if (Platform.isAndroid || Platform.isIOS) {
final env = {
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
};
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);
}
}
final dir = await getApplicationSupportDirectory();
root = "${dir.path}/gomuks".toNativeUtf8().cast();
} else {
root = nullptr.cast();
}
debugPrint("Initializing Gomuks...");
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
initialDeviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
),
).toJson().toGomuksBufferPtr();
final handle = GomuksInit(root);
try {
return GomuksInit(bufferPointer.ref);
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
}
});
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(() => 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(
Map<String, dynamic> data,
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
callback,
) async {
final bufferPointer = data.toGomuksBufferPtr();
try {
final handle = await future;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw 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(
Future<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);
]) async {
final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final response = await Isolate.run(
() => GomuksSubmitCommand(
handle,
command.toNativeUtf8().cast<Char>(),
bufferPointer.ref,
),
);
calloc.free(bufferPointer);
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
}
});
return json;
}
Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson());
@ -183,17 +213,21 @@ class ClientController extends AsyncNotifier<int> {
}
}
Future<String> joinRoom(JoinRoomRequest request) async =>
(await _sendCommand("join_room", request.toJson()))["room_id"];
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
Future<String> joinRoom(JoinRoomRequest request) async {
final response = await _sendCommand("join_room", request.toJson());
return response["room_id"];
}
Future<void> leaveRoom(Room room) async {
if (room.metadata == null) return;
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
}
// (await _sendCommand("get_event_context", {
// "room_id": request.roomId,
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
// }));
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
Future<List?> getState(GetRoomStateRequest request) async =>
(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)));
}
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 {
final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json);
@ -231,19 +259,8 @@ class ClientController extends AsyncNotifier<int> {
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
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<ProfileResponse> getProfile(String userId) async =>
.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson());
@ -254,15 +271,6 @@ class ClientController extends AsyncNotifier<int> {
Future<void> setAccountData(SetAccountDataRequest request) =>
_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 =>
.fromJson(await _sendCommand("upload_media", request.toJson()));
@ -272,11 +280,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout");
Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId];
if (event == null) return;
if (event == null || room.metadata == null) return;
await _sendCommand("mark_read", {
"room_id": room.metadata!.id,
@ -302,14 +308,10 @@ class ClientController extends AsyncNotifier<int> {
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async => Capabilities.fromJson(
(await _sendCommand("get_capabilities"))["capabilities"],
);
Future<Uri?> discoverHomeserver(Uri homeserver) async {
try {
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"]);
} catch (error) {

View file

@ -1,7 +1,10 @@
import "package:flutter_riverpod/flutter_riverpod.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
Future<String> build() => ref
.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/requests/get_event.dart";
class EventController(final GetEventRequest request)
extends AsyncNotifier<Event?> {
class EventController extends AsyncNotifier<Event?> {
final GetEventRequest request;
EventController(this.request);
@override
Future<Event?> build() async {
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: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 roomKey = "room";
static const String pushKeyKey = "pushKey";
@override
Future<String?> build() =>
ref.watch(SharedPrefsController.provider).getString(key);
String? build() =>
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
Future<void> set(String? value) async {
final prefs = ref.watch(SharedPrefsController.provider);
state = .data(value);
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
state = value;
if (value == null) {
await prefs.remove(key);
prefs.remove(key);
} else {
await prefs.setString(key, value);
prefs.setString(key, value);
}
}
static final provider =
AsyncNotifierProvider.family<KeyController, String?, String>(
NotifierProvider.family<KeyController, String?, String>(
KeyController.new,
);
}

View file

@ -1,20 +1,22 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/shared_prefs.dart";
class MemberListOpenedController extends AsyncNotifier<bool> {
class MemberListOpenedController extends Notifier<bool> {
static const String key = "memberListOpened";
@override
Future<bool> build() async =>
await ref.watch(SharedPrefsController.provider).getBool(key) ?? true;
bool build() =>
ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ??
true;
Future<void> set(bool value) async {
state = .data(value);
await ref.watch(SharedPrefsController.provider).setBool(key, value);
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
state = value;
prefs.setBool(key, value);
}
static final provider =
AsyncNotifierProvider<MemberListOpenedController, bool>(
MemberListOpenedController.new,
);
static final provider = NotifierProvider<MemberListOpenedController, bool>(
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/requests/get_room_state.dart";
class MembersController(final String roomId)
extends AsyncNotifier<ISet<Event>> {
class MembersController extends AsyncNotifier<ISet<Event>> {
final String roomId;
MembersController(this.roomId);
@override
Future<ISet<Event>> build() async {
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/event.dart";
class MembersByStatusController(final MembersByStatusConfig config)
extends AsyncNotifier<ISet<Event>> {
class MembersByStatusController extends AsyncNotifier<ISet<Event>> {
final MembersByStatusConfig config;
MembersByStatusController(this.config);
@override
Future<ISet<Event>> build() => ref.watch(
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/event.dart";
class MembersGroupedController(final MembersByStatusConfig config)
class MembersGroupedController
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
final MembersByStatusConfig config;
MembersGroupedController(this.config);
@override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch(

View file

@ -1,15 +1,14 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
extends AsyncNotifier<void> {
class MultiProviderController extends AsyncNotifier<void> {
MultiProviderController(this.providers);
final IList<AsyncNotifierProvider> providers;
@override
Future<void> build() => .wait(
providers.map((provider) => ref.watch(provider.future)),
eagerError: true,
);
Future<void> build() =>
.wait(providers.map((provider) => ref.watch(provider.future)));
static final provider =
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/models/event.dart";
class PinnedEventsController(final String roomId)
extends AsyncNotifier<IList<Event>> {
class PinnedEventsController extends AsyncNotifier<IList<Event>> {
final String roomId;
PinnedEventsController(this.roomId);
@override
Future<IList<Event>> build() async {
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
@ -13,8 +15,9 @@ class PinnedEventsController(final String roomId)
return (await Future.wait(
pinIds.map(
(eventId) => ref.watch(
EventController.provider(.new(eventId: eventId, roomId: roomId))
.future,
EventController.provider(
.new(eventId: eventId, roomId: roomId),
).future,
),
),
)).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/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
IList<String> build() {
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/power_levels.dart";
class PowerLevelController(final PowerLevelConfig config)
extends Notifier<bool> {
class PowerLevelController extends Notifier<bool> {
final PowerLevelConfig config;
PowerLevelController(this.config);
@override
bool build() {
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/models/profile_response.dart";
class ProfileController(final String userId)
extends AsyncNotifier<ProfileResponse> {
class ProfileController extends AsyncNotifier<ProfileResponse> {
final String userId;
ProfileController(this.userId);
@override
Future<ProfileResponse> build() {
final client = ref.read(ClientController.provider.notifier);
final client = ref.watch(ClientController.provider.notifier);
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/content/reaction.dart";
class ReactionsController(final ReactionsConfig config)
extends AsyncNotifier<IMap<String, IList<String>>> {
class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> {
final ReactionsConfig config;
ReactionsController(this.config);
@override
Future<IMap<String, IList<String>>> build() async {
final eventInfo = ref.watch(

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:math";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.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/models/content/content.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
class RoomChatController(final (String roomId, String? contextualEvent) info)
extends AsyncNotifier<RoomChat?> {
class RoomChatController extends AsyncNotifier<IList<Event>?> {
final String roomId;
Future<bool>? _loadingOlder;
RoomChatController(this.roomId);
@override
Future<RoomChat?> build() async {
final (roomId, eventId) = info;
final client = ref.read(ClientController.provider.notifier);
final room = ref.read(
Future<IList<Event>?> build() async {
final client = ref.watch(ClientController.provider.notifier);
final initialRoom = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return null;
if (initialRoom == null) return null;
Room room = initialRoom;
if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId));
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))
.map((element) => element.value)
.toIList()
.addAll(room.clientSticky)
.addAll(room.sticky)
.map((entry) {
final foundEvent = entry == null ? null : room.events[entry];
@ -57,26 +65,6 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
})
.nonNulls
.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
@ -84,101 +72,53 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
roomId: info.$1,
roomId: roomId,
reason: reason,
),
);
Future<void> paginate(Direction direction) async {
if (state.isLoading) return;
Future<bool> loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() {
_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
? chat?.hasMoreForward == false
: chat?.hasMoreBackward == false) {
return;
}
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(
roomId: info.$1,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) {
state = .data(state.value);
}
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
info.$1: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.$1,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
state = .data(
.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,
final timelineKeys = room.timeline.keys;
final response = await ref
.watch(ClientController.provider.notifier)
.paginate(
.new(
roomId: roomId,
maxTimelineId: timelineKeys.isNotEmpty
? timelineKeys.reduce(min)
: null,
),
),
);
}
);
ref
.watch(RoomsController.provider.notifier)
.update(
IMap({
roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
return response.hasMore;
}
Future<void> send(
@ -192,8 +132,8 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
if (relationType == .edit) {
baseContent = relation?.content;
} else {
final provider = AttachmentController.provider(info.$1);
baseContent = ref.read(provider)?.$2;
final provider = AttachmentController.provider(roomId);
baseContent = ref.watch(provider)?.$2;
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(
SendMessageRequest(
roomId: info.$1,
roomId: roomId,
baseContent: baseContent,
mentions: Mentions(
userIds: [
@ -231,12 +171,12 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
);
ref
.read(RoomsController.provider.notifier)
.watch(RoomsController.provider.notifier)
.update(
.new({
info.$1: .new(
roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
sticky: .new({event.rowId}),
),
}),
.new(),
@ -248,10 +188,10 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
Event event,
String userId,
) async {
final client = ref.read(ClientController.provider.notifier);
final client = ref.watch(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
.new(
roomId: info.$1,
roomId: roomId,
eventId: event.eventId,
relationType: "m.annotation",
),
@ -272,16 +212,16 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
if (reactionEvent != null) {
await ref
.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 {
final client = ref.read(ClientController.provider.notifier);
final client = ref.watch(ClientController.provider.notifier);
await client.sendEvent(
.new(
roomId: info.$1,
roomId: roomId,
type: EventType.reaction.type,
content: ReactionContent(key: reaction),
synchronous: true,
@ -293,7 +233,7 @@ class RoomChatController(final (String roomId, String? contextualEvent) info)
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, (String, String?)>(
.autoDispose<RoomChatController, IList<Event>?, String>(
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/room.dart";
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
class RoomCreatorsController extends Notifier<IList<String>> {
final Room room;
RoomCreatorsController(this.room);
@override
IList<String> build() {
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 "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
@ -47,10 +46,10 @@ class RoomsController extends Notifier<IMap<String, Room>> {
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
clientSticky:
(incoming.clientSticky.isEmpty == true
? existing.clientSticky
: existing.clientSticky.addAll(incoming.clientSticky))
sticky:
(incoming.sticky.isEmpty == true
? existing.sticky
: existing.sticky.addAll(incoming.sticky))
.removeWhere(
(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);
try {
return .fromJson(json.decode(await file.readAsString()));
return Settings.fromJson(json.decode(await file.readAsString()));
} catch (_) {
return .new();
return Settings();
}
}
Future<void> set(Settings settings) async {
state = .data(settings);
state = AsyncData(settings);
final file = await ref.watch(SettingsFileController.provider.future);
await file.writeAsString(json.encode(settings.toJson()));
}

View file

@ -1,19 +1,13 @@
import "dart:io";
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:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.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/notification.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/settings_category.dart";
import "package:nexus/main.dart";
@ -24,6 +18,9 @@ class SettingsSectionsController
@override
Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future);
final specVersionsResponse = await ref
.watch(ClientController.provider.notifier)
.getSpecVersions();
return .new({
"General": .new([
@ -52,7 +49,8 @@ class SettingsSectionsController
.new(
title: "Use Dynamic Theme",
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(
title: Text(title),
subtitle: Text(description),
@ -78,7 +76,8 @@ class SettingsSectionsController
settings: .new([
.new(
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,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
@ -99,141 +98,46 @@ class SettingsSectionsController
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
"Account": .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(
title: "Safety",
icon: Icons.gpp_good,
settings: .new([
.new(
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: (context, ref, _) {
final specVersionsResponse = ref.watch(
SpecVersionsController.provider,
);
return DialogListTile<DefaultInviteAction>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: ref
.watch(AccountDataController.provider)
.invitePermissionConfig
.defaultAction,
options:
specVersionsResponse.maybeWhen(
data: (data) => data.unstableFeatures.msc4494,
orElse: () => true,
)
? DefaultInviteAction.values
: IList(DefaultInviteAction.values)
.remove(.denyPublic)
.toList(),
getName: (option) => switch (option) {
.allow => "Allow",
.deny => "Deny",
.denyPublic => "Deny public",
},
onChanged: specVersionsResponse.maybeWhen(
data: (_) =>
(value) => ref
.watch(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.invitePermissionConfigKey,
content: InvitePermissionConfig(
defaultAction: value,
),
),
)
.onError(showError),
orElse: () => null,
builder: (context, ref, _) =>
DialogListTile<DefaultInviteAction>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: ref
.watch(AccountDataController.provider)
.invitePermissionConfig
.defaultAction,
options: specVersionsResponse.unstableFeatures.msc4494
? DefaultInviteAction.values
: IList(
DefaultInviteAction.values,
).remove(.denyPublic).toList(),
getName: (option) => switch (option) {
.allow => "Allow",
.deny => "Deny",
.denyPublic => "Deny public",
},
onChanged: (value) => ref
.watch(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.invitePermissionConfigKey,
content: InvitePermissionConfig(
defaultAction: value,
),
),
)
.onError(showError),
),
);
},
),
icon: Icons.person_off,
),
@ -252,14 +156,15 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon(
onPressed: () async {
await ref
.watch(UnifiedPushController.provider.notifier)
.deregister()
.onError(showError);
Navigator.of(
context,
).popUntil((route) => route.isFirst);
await WidgetsBinding.instance.endOfFrame;
await ref
.watch(ClientController.provider.notifier)
.logout()
.onError(showError);
.logout();
},
label: Text(title),
icon: Icon(icon),

View file

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

View file

@ -1,6 +1,6 @@
import "package:collection/collection.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:nexus/controllers/account_data.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/models/open_graph_data.dart";
class UrlPreviewController(final Uri url)
extends AsyncNotifier<OpenGraphData?> {
class UrlPreviewController extends AsyncNotifier<OpenGraphData?> {
final Uri url;
UrlPreviewController(this.url);
@override
Future<OpenGraphData?> build() async {
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/room.dart";
class ViaController(final Room room) extends Notifier<String> {
class ViaController extends Notifier<String> {
final Room room;
ViaController(this.room);
@override
String build() {
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")
/// 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 {
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 {
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:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:package_info_plus/package_info_plus.dart";
extension ShowAboutDialog on BuildContext {
extension ShowContextMenu on BuildContext {
Future<void> showAboutDialog(WidgetRef ref) async {
final packageInfo = await PackageInfo.fromPlatform();
@ -20,7 +20,7 @@ extension ShowAboutDialog on BuildContext {
Row(
spacing: 12,
children: [
SvgPicture.asset("assets/bundled/icon.svg", width: 64),
SvgPicture.asset("assets/icon.svg", width: 64),
Expanded(
child: Column(
crossAxisAlignment: .start,
@ -50,8 +50,8 @@ extension ShowAboutDialog on BuildContext {
M3ECardColumn(
onTap: (index) =>
ref.watch(LaunchHelper.provider).launchUrl(switch (index) {
0 => .https("git.federated.nexus", "nexus/nexus"),
_ => .https("liberapay.com", "QuadRadical"),
0 => Uri.https("git.federated.nexus", "nexus/nexus"),
_ => Uri.https("liberapay.com", "QuadRadical"),
}),
children: [
ListTile(

View file

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

View file

@ -1,5 +1,5 @@
import "package:color_hash/color_hash.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/material.dart";
extension ToColor on String {
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: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 {
try {
return await ul.launchUrl(

View file

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

View file

@ -1,31 +1,26 @@
import "dart:io";
import "package:dynamic_color/dynamic_color.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.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/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/notification.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/font_licenses.dart";
import "package:nexus/widgets/pages/chat.dart";
import "package:nexus/widgets/pages/select_server.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/pages/verify.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/pages/chat.dart";
import "package:nexus/pages/select_server.dart";
import "package:nexus/pages/verify.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.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>();
late final bool isInBackground;
final class Logger extends ProviderObserver {
@override
@ -42,7 +37,14 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
}
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;
}
@ -59,59 +61,49 @@ void showError(Object error, [StackTrace? stackTrace]) {
}
}
void main(List<String> args) async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
LicenseRegistry.addLicense(() => .fromIterable(fontLicenses));
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
}
LicenseRegistry.addLicense(() => Stream.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) {
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
}
runApp(
ProviderScope(
retry: (_, _) => null,
observers: [
// Change false to true if you want debug information on provider reloads
// ignore: dead_code
if (false && kDebugMode) Logger(),
],
child: App(),
),
);
}
runApp(
ProviderScope(
retry: (_, _) => null,
observers: [
// Change false to true if you want debug information on provider reloads
// ignore: dead_code
if (false && kDebugMode) Logger(),
],
child: const App(),
),
);
}
class const App({super.key}) extends StatelessWidget {
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => Consumer(
builder: (context, ref, child) => MaterialApp(
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
// Use indigo to work around bugs in theme generation
theme:
(ref
.watch(SettingsController.provider)
@ -142,55 +134,36 @@ class const App({super.key}) extends StatelessWidget {
),
child: Scaffold(
body: Consumer(
builder: (_, ref, _) => switch (ref.watch(
MultiProviderController.provider(
.new([
GomuksListenerController.provider,
NotificationController.provider,
UnifiedPushController.provider,
MemberListOpenedController.provider,
KeyController.provider(KeyController.roomKey),
KeyController.provider(KeyController.spaceKey),
]),
),
)) {
AsyncData(value: _) || AsyncLoading(value: _?) => Consumer(
builder: (_, ref, _) {
final clientState = ref.watch(ClientStateController.provider);
builder: (_, ref, _) => ref
.watch(
MultiProviderController.provider(
IListConst([
SharedPrefsController.provider,
ClientController.provider,
]),
),
)
.betterWhen(
data: (_) => Consumer(
builder: (_, ref, _) {
final clientState = ref.watch(
ClientStateController.provider,
);
if (clientState == null || !clientState.isInitialized) {
return Loading();
}
if (clientState == null || !clientState.isInitialized) {
return Loading();
}
if (!clientState.isLoggedIn) {
return SelectServerPage();
} else if (!clientState.isVerified) {
return VerifyPage();
} else {
return ChatPage();
}
},
),
AsyncLoading _ => Scaffold(
appBar: Appbar(
actions: .new([
IconButton(
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: Icon(Icons.settings),
),
]),
if (!clientState.isLoggedIn) {
return SelectServerPage();
} else if (!clientState.isVerified) {
return VerifyPage();
} else {
return ChatPage();
}
},
),
),
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:freezed_annotation/freezed_annotation.dart";
part "account_data.freezed.dart";
part "account_data.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const AccountData({
@JsonKey(name: AccountData.invitePermissionConfigKey)
final InvitePermissionConfig invitePermissionConfig =
const InvitePermissionConfig(),
@freezed
abstract class AccountData with _$AccountData {
const AccountData._();
static List<dynamic>? readRecentEmojiValue(
Map<dynamic, dynamic> json,
String key,
) => json[key]?["recent_emoji"];
@JsonKey(name: AccountData.directKey)
final IMap<String, IList<String>> directMessages = const IMap.empty(),
static Map<String, List<dynamic>>? recentEmojiToJson(
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 directKey = "m.direct";
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) =>
_$AccountDataFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RecentEmojiData({
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)
final DefaultInviteAction defaultAction = DefaultInviteAction.allow,
}) with _$InvitePermissionConfig {
Map<String, Object?> toJson() => _$InvitePermissionConfigToJson(this);
@freezed
abstract class InvitePermissionConfig with _$InvitePermissionConfig {
const factory InvitePermissionConfig({
@JsonKey(unknownEnumValue: DefaultInviteAction.allow)
@Default(DefaultInviteAction.allow)
DefaultInviteAction defaultAction,
}) = _InvitePermissionConfig;
factory InvitePermissionConfig.fromJson(Map<String, Object?> json) =>
_$InvitePermissionConfigFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RecentEmoji({required final String emoji, required final int total})
with _$RecentEmoji {
Map<String, Object?> toJson() => _$RecentEmojiToJson(this);
@freezed
abstract class RecentEmoji with _$RecentEmoji {
const factory RecentEmoji({required String emoji, required int total}) =
_RecentEmoji;
factory RecentEmoji.fromJson(Map<String, Object?> 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";
part "client_state.freezed.dart";
part "client_state.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const ClientState({
required final bool isInitialized,
required final bool isLoggedIn,
required final bool isVerified,
required final String? userId,
required final String? deviceId,
required final String? homeserverUrl,
}) with _$ClientState {
Map<String, Object?> toJson() => _$ClientStateToJson(this);
@freezed
abstract class ClientState with _$ClientState {
const factory ClientState({
required bool isInitialized,
required bool isLoggedIn,
required bool isVerified,
required String? userId,
required String? homeserverUrl,
}) = _ClientState;
factory ClientState.fromJson(Map<String, Object?> json) =>
_$ClientStateFromJson(json);

View file

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

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