1
0
Fork 0
forked from Nexus/nexus

Compare commits

..

No commits in common. "a1401f20ea3238e20c15c4a0329da2c0abd09442" and "4c69831c54d422726cc92de7a5201f27ed384c52" have entirely different histories.

64 changed files with 465 additions and 948 deletions

View file

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

View file

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

View file

@ -1,50 +1,46 @@
name: "Build EXE"
name: "Build Windows Version"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-exe:
runs-on: windows-latest
build-windows:
runs-on: "windows-latest"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: "Checkout repository"
uses: "actions/checkout@v4"
- name: "Set up Flutter"
uses: "subosito/flutter-action@v2"
- name: "Set up Rust"
uses: "dtolnay/rust-toolchain@stable"
with:
submodules: recursive
targets: "x86_64-pc-windows-msvc"
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.41.5
- name: "Install Flutter dependencies"
run: flutter pub get
- name: Set up Go
uses: actions/setup-go@v6
- name: Build with Flutter
- name: "Run build_runner & build Windows EXE"
run: |
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter pub run build_runner build --delete-conflicting-outputs
flutter build windows --release
- name: Upload exe zip
uses: actions/upload-artifact@v6
- name: "Upload exe zip"
uses: "actions/upload-artifact@v4"
with:
name: windows-portable
path: build/windows/x64/runner/Release/
name: "windows-portable"
path: "build/windows/x64/runner/Release/"
- name: Install Inno Setup
- name: "Install Inno Setup"
run: choco install innosetup -y
- name: Build Inno Setup installer
- name: "Build Inno Setup installer"
run: iscc windows/installer.iss
- name: Upload installer artifact
uses: actions/upload-artifact@v6
- name: "Upload installer artifact"
uses: "actions/upload-artifact@v4"
with:
name: windows-installer
path: windows/dist/Nexus-Setup.exe
name: "windows-installer"
path: "windows/dist/Nexus-Setup.exe"

4
.gitignore vendored
View file

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

4
.gitmodules vendored
View file

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

View file

@ -5,7 +5,7 @@
## Description
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
A simple and user-friendly Matrix client made with Flutter and the Matrix Dart SDK.
## Screenshots
@ -17,8 +17,8 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] New logo
- [ ] Make context menus appear as bottom sheets on mobile
- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2
- [ ] Allow using remote Gomuks over websocket
- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2
- [ ] Allow using remote gomuks over websocket
- [ ] Platform Support
- [x] Linux
- [x] Windows
@ -54,7 +54,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] HTML/Markdown
- [x] Replies
- [x] Choose ping on/off
- [ ] Per message profiles
- [ ] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Mentions
@ -65,7 +64,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] GIFs using Gomuks' GIF proxies
- [x] Recieving
- [x] Plain text
- [x] Per message profiles
- [x] HTML
- [x] Replies
- [x] Viewing
@ -130,7 +128,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
First, clone and open the repo:
```sh
git clone --recurse-submodules https://git.federated.nexus/Henry-Hiles/nexus
git clone https://git.federated.nexus/Henry-Hiles/nexus
cd nexus
```
@ -138,12 +136,12 @@ cd nexus
#### Linux
- With Nix: Either use direnv and `direnv allow`, or `nix flake develop`
- With Nix: Either use direnv, or `nix flake develop`
- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc.
#### Windows / MacOS
I don't really know. You will need Flutter, Git, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM.
I don't really know. You will need Flutter, Git, Olm, Go, and Visual Studio tools, and otherwise I guess just keep installing stuff until there aren't any errors. I will look into this sometimeTM.
### Set up Flutter
@ -153,7 +151,13 @@ Get dependencies:
flutter pub get
```
Generate Gomuks bindings:
Get dependencies:
```sh
flutter pub get
```
Clone Gomuks and generate bindings:
```sh
scripts/generate.sh

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View file

@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="background.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="1.0847363"
inkscape:cx="57.156749"
inkscape:cy="214.33781"
inkscape:window-width="1904"
inkscape:window-height="971"
inkscape:window-x="35"
inkscape:window-y="32"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><linearGradient
id="linearGradient10"
inkscape:collect="always"><stop
style="stop-color:#c7a312;stop-opacity:1;"
offset="0"
id="stop10" /><stop
style="stop-color:#26a0b3;stop-opacity:1;"
offset="1"
id="stop11" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient10"
id="linearGradient11"
x1="20.031296"
y1="32.697563"
x2="90.709213"
y2="66.3423"
gradientUnits="userSpaceOnUse" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><rect
style="fill:url(#linearGradient11);fill-opacity:1;stroke:none;stroke-width:7.99999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none"
id="rect10"
width="100"
height="100"
x="0"
y="0"
ry="28.294127" /></g></svg>

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

BIN
assets/reply-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/reply.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

81
flake.lock generated
View file

@ -18,54 +18,17 @@
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix2flatpak": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1774604963,
"narHash": "sha256-MtAW1FIdirSlUAAO7s1u9auv5y3I6t3uJ+GeEbqiqxI=",
"owner": "neobrain",
"repo": "nix2flatpak",
"rev": "3e04657fbcb49956ac301410b071a7f0b2ad5988",
"type": "github"
},
"original": {
"owner": "neobrain",
"repo": "nix2flatpak",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773389992,
"narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=",
"owner": "NixOS",
"lastModified": 1767640445,
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c06b4ae3d6599a672a6210b7021d699c351eebda",
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
"type": "github"
},
"original": {
"owner": "NixOS",
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
@ -86,42 +49,10 @@
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1767640445,
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nix2flatpak": "nix2flatpak",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
"nixpkgs": "nixpkgs"
}
}
},

View file

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

1
gomuks

@ -1 +0,0 @@
Subproject commit daa0ba028e7d89ba9fc7580fc8099348e6145cb3

View file

@ -3,12 +3,11 @@ import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart";
Future<void> main(List<String> args) => build(args, (input, output) async {
final codeConfig = input.config.code;
final targetOS = codeConfig.targetOS;
final targetArch = codeConfig.targetArchitecture;
final buildDir = input.packageRoot.resolve("src/");
if (await File(buildDir.resolve("lock").toFilePath()).exists()) return;
final targetOS = input.config.code.targetOS;
String libFileName;
Map<String, String> env = {};
switch (targetOS) {
case OS.linux:
libFileName = "libgomuks.so";
@ -18,60 +17,24 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
break;
case OS.windows:
libFileName = "libgomuks.dll";
env = {"GOCACHE": r"C:\Users\runneradmin\AppData\Local\go-build"};
break;
case OS.android:
libFileName = "libgomuks.so";
final targetNdkApi = codeConfig.android.targetNdkApi;
final ndkHome =
Platform.environment["ANDROID_NDK_HOME"] ??
Platform.environment["ANDROID_NDK_ROOT"] ??
Platform.environment["NDK_HOME"] ??
await _findNdkFromSdk();
if (ndkHome == null) {
throw Exception(
"Could not find Android NDK. Set ANDROID_NDK_HOME or install via sdkmanager.",
);
}
final hostTag = _ndkHostTag();
final (goArch, ccTriple) = _androidArch(targetArch);
final cc =
"$ndkHome/toolchains/llvm/prebuilt/$hostTag/bin/$ccTriple$targetNdkApi-clang";
env = {"CGO_ENABLED": "1", "GOOS": "android", "GOARCH": goArch, "CC": cc};
break;
default:
throw UnsupportedError("Unsupported OS: $targetOS");
}
var libFile = input.packageRoot.resolve(libFileName);
final gomuksBuildDir = input.packageRoot.resolve("gomuks/");
final gomuksBuildDir = buildDir.resolve("gomuks/");
final libFile = gomuksBuildDir.resolve(libFileName);
if (!(await File.fromUri(libFile).exists())) {
final buildDir = input.packageRoot.resolve("build/");
libFile = buildDir.resolve("${targetArch.name}/$libFileName");
print("Building Gomuks shared library $libFileName from source...");
final result = await Process.run("go", [
"build",
"-o",
libFile.path,
"-buildmode=c-shared",
], workingDirectory: gomuksBuildDir.resolve("source/pkg/ffi/").toFilePath());
// goheif/dav1d supported on Android would need to fix upstream
final tags = targetOS == OS.android ? "goolm,noheic" : "goolm";
print(
"Building Gomuks shared library $libFileName (${targetOS.name}/${targetArch.name}) from source...",
);
final result = await Process.run(
"go",
["build", "-tags", tags, "-o", libFile.path, "-buildmode=c-shared"],
workingDirectory: gomuksBuildDir.resolve("pkg/ffi/").toFilePath(),
environment: env.isNotEmpty ? env : null,
);
if (result.exitCode != 0) {
throw Exception(
"Failed to build Gomuks shared library\n${result.stderr}",
);
}
if (result.exitCode != 0) {
throw Exception("Failed to build Gomuks shared library\n${result.stderr}");
}
final generatedFile = "src/third_party/gomuks.g.dart";
@ -89,43 +52,3 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
..dependencies.add(gomuksBuildDir);
print("Done!");
});
Future<String?> _findNdkFromSdk() async {
// pretty sure this wont be needed with nix, i'll get this removed
final androidHome =
Platform.environment["ANDROID_HOME"] ??
Platform.environment["ANDROID_SDK_ROOT"];
if (androidHome == null) return null;
final ndkDir = Directory("$androidHome/ndk");
if (!await ndkDir.exists()) return null;
final versions = await ndkDir.list().toList();
if (versions.isEmpty) return null;
versions.sort((a, b) => a.path.compareTo(b.path));
return versions.last.path;
}
String _ndkHostTag() {
if (Platform.isMacOS) {
return "darwin-x86_64";
} else if (Platform.isLinux) {
return "linux-x86_64";
} else if (Platform.isWindows) {
return "windows-x86_64";
}
throw UnsupportedError("Unsupported host platform for Android NDK");
}
(String goArch, String ccTriple) _androidArch(Architecture arch) {
switch (arch) {
case Architecture.arm64:
return ("arm64", "aarch64-linux-android");
case Architecture.arm:
return ("arm", "armv7a-linux-androideabi");
case Architecture.x64:
return ("amd64", "x86_64-linux-android");
case Architecture.ia32:
return ("386", "i686-linux-android");
default:
throw UnsupportedError("Unsupported Android architecture: $arch");
}
}

View file

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

View file

@ -1,44 +0,0 @@
import "dart:async";
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/members_controller.dart";
import "package:nexus/models/configs/author_config.dart";
import "package:nexus/models/membership.dart";
class AuthorController extends AsyncNotifier<Membership> {
final AuthorConfig config;
AuthorController(this.config);
@override
Future<Membership> build() async {
var member = await ref.watch(
MembersController.provider(config.room).selectAsync(
(value) => value.firstWhereOrNull(
(membership) => membership.userId == config.message.authorId,
),
),
);
final pmp = config.message.metadata?["pmp"] == null
? null
: Membership.fromContent(
IMap(config.message.metadata?["pmp"]),
config.message.authorId,
);
return Membership(
avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl,
displayName:
pmp?.displayName ??
member?.displayName ??
config.message.authorId.substring(1).split(":").first,
userId: config.message.authorId,
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<AuthorController, Membership, AuthorConfig>(
AuthorController.new,
);
}

View file

@ -1,6 +1,5 @@
import "dart:developer";
import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
@ -32,20 +31,11 @@ import "package:nexus/models/sync_data.dart";
import "package:nexus/models/sync_status.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart";
class ClientController extends AsyncNotifier<int> {
@override
Future<int> build() async {
final Pointer<Char> root;
if (Platform.isAndroid) {
final dir = await getApplicationSupportDirectory();
root = "${dir.path}/gomuks".toNativeUtf8().cast();
} else {
root = nullptr.cast();
}
final handle = GomuksInit(root);
final handle = await Isolate.run(GomuksInit);
final callable =
NativeCallable<
@ -153,12 +143,12 @@ class ClientController extends AsyncNotifier<int> {
Future<void> sendMessage(SendMessageRequest request) =>
_sendCommand("send_message", request.toJson());
Future<String?> verify(String recoveryKey) async {
Future<bool> verify(String recoveryKey) async {
try {
await _sendCommand("verify", {"recovery_key": recoveryKey});
return null;
return true;
} catch (error) {
return error.toString();
return false;
}
}
@ -228,12 +218,12 @@ class ClientController extends AsyncNotifier<int> {
});
}
Future<String?> login(LoginRequest login) async {
Future<bool> login(LoginRequest login) async {
try {
await _sendCommand("login", login.toJson());
return null;
return true;
} catch (error) {
return error.toString();
return false;
}
}

View file

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

View file

@ -2,8 +2,9 @@ import "package:collection/collection.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/message_config.dart";
class MessageController extends AsyncNotifier<Message?> {
final MessageConfig config;
@ -26,6 +27,12 @@ class MessageController extends AsyncNotifier<Message?> {
if (!ref.mounted) return null;
final members = ref.read(MembersController.provider(config.room));
final author = members.firstWhereOrNull(
(member) => member.stateKey == event.authorId,
);
if (!ref.mounted) return null;
final content = (event.decrypted ?? event.content);
final type = (config.event.decryptedType ?? config.event.type);
final newContent = content["m.new_content"] as Map?;
@ -45,11 +52,14 @@ class MessageController extends AsyncNotifier<Message?> {
"timelineId": event.timelineRowId,
"big": event.localContent?.bigEmoji == true,
"eventType": type,
"pmp": event.content["com.beeper.per_message_profile"],
"avatarUrl": author?.content["avatar_url"],
"editSource":
event.localContent?.editSource ??
newContent?["body"] ??
content["body"],
"displayName": author?.content["displayname"]?.isNotEmpty == true
? author?.content["displayname"]
: event.authorId.substring(1).split(":")[0],
"txnId": config.event.transactionId,
};

View file

@ -2,8 +2,8 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/configs/messages_config.dart";
import "package:nexus/models/message_config.dart";
import "package:nexus/models/messages_config.dart";
class MessagesController extends AsyncNotifier<IList<Message>> {
final MessagesConfig config;

View file

@ -1,4 +1,5 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
@ -10,8 +11,8 @@ import "package:nexus/controllers/message_controller.dart";
import "package:nexus/controllers/messages_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/configs/messages_config.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/message_config.dart";
import "package:nexus/models/messages_config.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart";
@ -30,7 +31,11 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
if (room == null) return InMemoryChatController();
final state = await client.getRoomState(
GetRoomStateRequest(roomId: roomId),
GetRoomStateRequest(
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList == false,
includeMembers: true,
),
);
ref

View file

@ -36,7 +36,6 @@ class RoomsController extends Notifier<IMap<String, Room>> {
return acc.add(
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
metadata: incoming.metadata ?? existing.metadata,
events: events!,
state: incoming.state.entries.fold(

View file

@ -15,7 +15,6 @@ extension JoinRoomWithSnackbars on ClientController {
WidgetRef ref,
) async {
final roomIdOrAlias = roomAlias.mention ?? roomAlias;
// TODO: Parse vias properly
final scaffoldMessenger = ScaffoldMessenger.of(context);

View file

@ -18,6 +18,7 @@ import "package:nexus/widgets/loading.dart";
import "package:window_manager/window_manager.dart";
import "package:flutter/material.dart";
import "package:dynamic_system_colors/dynamic_system_colors.dart";
import "package:window_size/window_size.dart";
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -58,11 +59,14 @@ void showError(Object error, [StackTrace? stackTrace]) {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(titleBarStyle: TitleBarStyle.hidden),
);
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(titleBarStyle: TitleBarStyle.hidden),
);
if (Platform.isLinux) {
setWindowMinSize(const Size.square(500));
} else {
await windowManager.setMinimumSize(Size.square(500));
}

View file

@ -1,14 +0,0 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart";
part "author_config.freezed.dart";
part "author_config.g.dart";
@freezed
abstract class AuthorConfig with _$AuthorConfig {
const factory AuthorConfig({required Message message, required Room room}) =
_AuthorConfig;
factory AuthorConfig.fromJson(Map<String, Object?> json) =>
_$AuthorConfigFromJson(json);
}

View file

@ -1,22 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "membership.freezed.dart";
@freezed
abstract class Membership with _$Membership {
const Membership._();
const factory Membership({
required Uri? avatarUrl,
required String displayName,
required String userId,
}) = _Membership;
factory Membership.fromContent(
IMap<String, dynamic> content,
String userId,
) => Membership(
avatarUrl: Uri.tryParse(content["avatar_url"] ?? ""),
userId: userId,
displayName: content["displayname"] ?? userId.substring(1).split(":").first,
);
}

View file

@ -6,7 +6,7 @@ part "get_room_state_request.g.dart";
abstract class GetRoomStateRequest with _$GetRoomStateRequest {
const factory GetRoomStateRequest({
required String roomId,
@Default(false) bool fetchMembers,
required bool fetchMembers,
@Default(false) bool includeMembers,
}) = _GetRoomStateRequest;

View file

@ -16,7 +16,7 @@ class ChatPage extends ConsumerWidget {
body: Builder(
builder: (context) => Row(
children: [
if (isDesktop) Sidebar(isDesktop: isDesktop),
if (isDesktop) Sidebar(),
Expanded(
child: RoomChat(
isDesktop: isDesktop,
@ -26,7 +26,7 @@ class ChatPage extends ConsumerWidget {
],
),
),
drawer: isDesktop ? null : Sidebar(isDesktop: isDesktop),
drawer: isDesktop ? null : Sidebar(),
);
},
);

View file

@ -175,7 +175,7 @@ class LoginPage extends HookConsumerWidget {
ElevatedButton(
onPressed: () async {
isLoading.value = true;
final error = await client.login(
final succeeded = await client.login(
LoginRequest(
username: username.text,
password: password.text,
@ -183,11 +183,11 @@ class LoginPage extends HookConsumerWidget {
),
);
if (error != null && context.mounted) {
if (!succeeded && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Login failed. Is your password right?\nError: $error",
"Login failed. Is your password right?",
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),

View file

@ -2,7 +2,6 @@ import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/form_text_input.dart";
class VerifyPage extends HookConsumerWidget {
@ -12,75 +11,72 @@ class VerifyPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final passphraseController = useTextEditingController();
final isVerifying = useState(false);
return Scaffold(
appBar: Appbar(),
body: AlertDialog(
title: Text("Verify"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.",
),
SizedBox(height: 12),
FormTextInput(
required: false,
autofocus: true,
capitalize: true,
controller: passphraseController,
obscure: true,
title: "Recovery Key or Passphrase",
),
],
),
actions: [
TextButton(
onPressed: isVerifying.value
? null
: () async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final snackbar = scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"Attempting to verify with recovery key...",
),
duration: Duration(days: 999),
),
);
isVerifying.value = true;
final error = await ref
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
snackbar.close();
if (error != null) {
isVerifying.value = false;
if (context.mounted) {
scaffoldMessenger.showSnackBar(
SnackBar(
backgroundColor: Theme.of(
context,
).colorScheme.errorContainer,
content: Text(
"Verification failed. Is your passphrase correct?\nError: $error",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
),
),
);
}
}
},
child: Text("Verify"),
return AlertDialog(
title: Text("Verify"),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.",
),
SizedBox(height: 12),
FormTextInput(
required: false,
autofocus: true,
capitalize: true,
controller: passphraseController,
obscure: true,
title: "Recovery Key or Passphrase",
),
],
),
actions: [
TextButton(
onPressed: isVerifying.value
? null
: () async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final snackbar = scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"Attempting to verify with recovery key...",
),
duration: Duration(days: 999),
),
);
isVerifying.value = true;
final success = await ref
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
snackbar.close();
if (!success) {
isVerifying.value = false;
if (context.mounted) {
scaffoldMessenger.showSnackBar(
SnackBar(
backgroundColor: Theme.of(
context,
).colorScheme.errorContainer,
content: Text(
"Verification failed. Is your passphrase correct?",
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
),
),
);
}
}
},
child: Text("Verify"),
),
],
);
}
}

View file

@ -35,14 +35,15 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTap: maximize,
onPanStart: (_) => windowManager.startDragging(),
child: AppBar(
leading: leading,
backgroundColor: backgroundColor,
scrolledUnderElevation: scrolledUnderElevation,
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
title: IgnorePointer(child: title),
flexibleSpace: GestureDetector(onDoubleTap: maximize),
title: title,
actions: [
...actions,
if (!(Platform.isAndroid || Platform.isIOS)) ...[

View file

@ -1,3 +1,4 @@
import "dart:io";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
@ -7,8 +8,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/composer/mention_overlay.dart";
import "package:nexus/widgets/chat_page/composer/relation_preview.dart";
import "package:nexus/widgets/chat_page/mention_overlay.dart";
import "package:nexus/widgets/chat_page/relation_preview.dart";
class ChatBox extends HookConsumerWidget {
final Message? relatedMessage;
@ -54,15 +55,20 @@ class ChatBox extends HookConsumerWidget {
final node = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
onDismiss();
return KeyEventResult.handled;
if (event is KeyDownEvent && !Platform.isAndroid && !Platform.isIOS) {
if (event.logicalKey == LogicalKeyboardKey.enter &&
!HardwareKeyboard.instance.isShiftPressed) {
send();
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
onDismiss();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
);
)..requestFocus();
final style = TextStyle(
color: theme.colorScheme.primary,
@ -80,11 +86,10 @@ class ChatBox extends HookConsumerWidget {
child: Column(
children: [
RelationPreview(
relatedMessage,
room: room,
shouldMention: shouldMention.value,
toggleShouldMention: () =>
shouldMention.value = !shouldMention.value,
relatedMessage: relatedMessage,
relationType: relationType,
onDismiss: onDismiss,
),
@ -150,9 +155,7 @@ class ChatBox extends HookConsumerWidget {
),
controller: controller.value,
key: key,
// TODO: Setting for send on enter on / off
onFieldSubmitted: (_) => send(),
textInputAction: TextInputAction.done,
autofocus: true,
focusNode: node,
),
),

View file

@ -22,10 +22,6 @@ class Html extends ConsumerWidget {
html,
textStyle: textStyle,
customWidgetBuilder: (element) {
if (element.attributes.keys.contains("data-mx-profile-fallback")) {
return SizedBox.shrink();
}
if (element.attributes.keys.contains("data-mx-spoiler")) {
return InlineCustomWidget(child: SpoilerText(text: element.text));
}

View file

@ -1,30 +0,0 @@
import "package:flutter/widgets.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/author_config.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MessageAvatar extends ConsumerWidget {
final Message message;
final Room room;
final double height;
const MessageAvatar(this.message, this.room, {this.height = 16, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(
AuthorController.provider(AuthorConfig(room: room, message: message)),
)
.betterWhen(
data: (membership) => AvatarOrHash(
membership.avatarUrl,
membership.displayName,
height: height,
),
loading: () =>
AvatarOrHash(null, message.authorId.substring(1), height: height),
);
}

View file

@ -1,28 +0,0 @@
import "package:flutter/widgets.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/author_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/author_config.dart";
import "package:nexus/models/room.dart";
class MessageDisplayname extends ConsumerWidget {
final Message message;
final Room room;
final TextStyle? style;
const MessageDisplayname(this.message, this.room, {this.style, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(
AuthorController.provider(AuthorConfig(room: room, message: message)),
)
.betterWhen(
data: (membership) => Text(
"${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}",
style: style,
overflow: TextOverflow.ellipsis,
),
loading: () => Text(""),
);
}

View file

@ -1,7 +1,6 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
@ -11,17 +10,15 @@ class MemberList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final membersProvider = ref.watch(MembersController.provider(room));
final members = ref.watch(MembersController.provider(room));
return Drawer(
shape: Border(),
child: Column(
child: ListView(
children: [
AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.people),
title: Text(
"Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}",
),
title: Text("Members (${members.length})"),
actionsPadding: EdgeInsets.only(right: 4),
actions: [
if (Scaffold.of(context).hasEndDrawer)
@ -32,32 +29,24 @@ class MemberList extends ConsumerWidget {
),
],
),
membersProvider.betterWhen(
data: (members) => Expanded(
child: ListView(
children: members
.map(
(member) => ListTile(
onTap: () => showDialog(
context: context,
builder: (context) =>
Dialog(child: Text("TODO: Open member popover")),
),
leading: AvatarOrHash(
member.avatarUrl,
member.displayName,
),
title: Text(
member.displayName,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
member.userId,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
...members.map(
(member) => ListTile(
onTap: () => showDialog(
context: context,
builder: (context) =>
Dialog(child: Text("TODO: Open member popover")),
),
leading: AvatarOrHash(
Uri.tryParse(member.content["avatar_url"] ?? ""),
member.content["displayname"].toString(),
),
title: Text(
member.content["displayname"].toString(),
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
member.stateKey ?? "Unknown User",
overflow: TextOverflow.ellipsis,
),
),
),

View file

@ -2,7 +2,6 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/loading.dart";
@ -32,47 +31,55 @@ class MentionOverlay extends ConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainerHigh,
padding: EdgeInsets.all(8),
child: switch (triggerCharacter) {
"@" =>
ref
.watch(MembersController.provider(room))
.betterWhen(
data: (members) => ListView(
children:
(query.isEmpty
? members
: members.where(
(member) =>
member.userId.toLowerCase().contains(
query.toLowerCase(),
) ==
true ||
member.displayName
.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true,
))
.map(
(member) => ListTile(
leading: AvatarOrHash(
member.avatarUrl,
member.displayName,
),
title: Text(member.displayName),
subtitle: Text(member.userId),
onTap: () => addTag(
id: "[@${member.displayName}](https://matrix.to/#/${member.userId})",
name: member.userId
.substring(1)
.split(":")
.first,
),
"@" => Consumer(
builder: (_, ref, _) {
final members = ref.watch(MembersController.provider(room));
return ListView(
children:
(query.isEmpty
? members
: members.where(
(member) =>
member.stateKey?.toLowerCase().contains(
query.toLowerCase(),
) ==
true ||
(member.content["displayname"] as String?)
?.toLowerCase()
.contains(query.toLowerCase()) ==
true,
))
.map(
(member) => ListTile(
leading: AvatarOrHash(
Uri.tryParse(
member.content["avatar_url"] ?? "",
),
)
.toList(),
),
),
member.content["displayname"] ?? "",
),
title: Text(
member.content["displayname"] as String? ??
member.stateKey ??
"Unknown User",
),
subtitle: member.stateKey != null
? Text(member.stateKey!)
: null,
onTap: () => addTag(
id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.stateKey})",
name:
member.stateKey
?.substring(1)
.split(":")
.first ??
"Unknown User",
),
),
)
.toList(),
);
},
),
"#" => ListView(
children:
(query.isEmpty

View file

@ -1,21 +1,12 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MessageWrapper extends StatelessWidget {
final Message message;
final Widget child;
final Room room;
final MessageGroupStatus? groupStatus;
const MessageWrapper(
this.message,
this.child,
this.groupStatus,
this.room, {
super.key,
});
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
@override
Widget build(BuildContext context) => ClipRRect(
@ -33,7 +24,11 @@ class MessageWrapper extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
groupStatus?.isFirst != false
? MessageAvatar(message, room, height: 40)
? AvatarOrHash(
Uri.parse(message.metadata?["avatarUrl"] ?? ""),
height: 40,
message.metadata?["displayName"] ?? "",
)
: SizedBox(width: 40),
Expanded(
child: Column(
@ -41,9 +36,9 @@ class MessageWrapper extends StatelessWidget {
spacing: 4,
children: [
if (groupStatus?.isFirst != false)
MessageDisplayname(
message,
room,
Text(
message.metadata?["displayName"] ?? message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),

View file

@ -2,9 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RelationPreview extends ConsumerWidget {
final Message? relatedMessage;
@ -12,11 +10,8 @@ class RelationPreview extends ConsumerWidget {
final VoidCallback onDismiss;
final bool shouldMention;
final VoidCallback toggleShouldMention;
final Room room;
const RelationPreview(
this.relatedMessage, {
required this.room,
const RelationPreview({
required this.relatedMessage,
required this.relationType,
required this.onDismiss,
required this.shouldMention,
@ -41,10 +36,14 @@ class RelationPreview extends ConsumerWidget {
"Editing message:",
style: TextStyle(fontWeight: FontWeight.bold),
),
MessageAvatar(relatedMessage!, room),
MessageDisplayname(
relatedMessage!,
room,
AvatarOrHash(
Uri.tryParse(relatedMessage?.metadata?["avatarUrl"] ?? ""),
relatedMessage?.metadata?["displayName"]?.toString() ?? "",
height: 16,
),
Text(
relatedMessage!.metadata?["displayName"] ??
relatedMessage!.authorId,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),

View file

@ -1,15 +1,15 @@
import "dart:math";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event_controller.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/message_config.dart";
import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
typedef OnTapReply = void Function(Message message)?;
@ -61,28 +61,73 @@ class ReplyWidget extends ConsumerWidget {
return SizedBox.shrink();
}
final smallerText =
message is TextMessage &&
replyMessage.metadata?["body"] != null
? replyMessage.metadata!["body"].substring(
0,
min(
max(
max(
(message as TextMessage)
.text
.length -
(replyMessage
.metadata?["displayName"]
as String)
.length -
5,
message
.metadata?["displayName"]
.length,
),
5,
),
replyMessage.metadata!["body"].length,
),
)
: null;
final replyText =
(smallerText == null ||
smallerText.length ==
replyMessage
.metadata!["body"]
.length)
? replyMessage.metadata!["body"]
: "$smallerText...";
return InkWell(
onTap: () => onTapReply?.call(replyMessage),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
MessageAvatar(replyMessage, room),
AvatarOrHash(
Uri.tryParse(
replyMessage.metadata?["avatarUrl"] ??
"",
),
replyMessage.metadata?["displayName"] ??
"",
height: 16,
),
Flexible(
child: MessageDisplayname(
replyMessage,
room,
child: Text(
replyMessage
.metadata?["displayName"] ??
replyMessage.authorId,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Text(
replyMessage.metadata!["body"],
replyText,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,

View file

@ -13,14 +13,15 @@ import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/report_request.dart";
import "package:nexus/widgets/chat_page/composer/chat_box.dart";
import "package:nexus/widgets/chat_page/chat_box.dart";
import "package:nexus/widgets/chat_page/image_message.dart";
import "package:nexus/widgets/chat_page/member_list.dart";
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/message_wrapper.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart";
import "package:nexus/widgets/chat_page/text_message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart";
import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/widgets/loading.dart";
// import "package:dynamic_polls/dynamic_polls.dart";
class RoomChat extends HookConsumerWidget {
@ -232,7 +233,7 @@ class RoomChat extends HookConsumerWidget {
children: getMessageOptions(message),
),
builders: Builders(
loadMoreBuilder: (_) => SizedBox.shrink(),
loadMoreBuilder: (_) => Loading(),
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
@ -319,7 +320,6 @@ class RoomChat extends HookConsumerWidget {
),
),
groupStatus,
room,
),
systemMessageBuilder:

View file

@ -12,8 +12,7 @@ import "package:nexus/widgets/chat_page/room_menu.dart";
import "package:nexus/widgets/form_text_input.dart";
class Sidebar extends HookConsumerWidget {
final bool isDesktop;
const Sidebar({required this.isDesktop, super.key});
const Sidebar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -221,12 +220,9 @@ class Sidebar extends HookConsumerWidget {
),
)
.toList(),
onDestinationSelected: (value) {
selectedRoomIdNotifier.set(
selectedSpace.children[value].metadata?.id,
);
if (!isDesktop) Navigator.of(context).pop();
},
onDestinationSelected: (value) => selectedRoomIdNotifier.set(
selectedSpace.children[value].metadata?.id,
),
),
),
),

View file

@ -3,7 +3,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
import "package:nexus/widgets/chat_page/message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart";
class TextMessageWrapper extends StatelessWidget {
@ -109,7 +109,6 @@ class TextMessageWrapper extends StatelessWidget {
),
),
groupStatus,
room,
);
}
}

View file

@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "nexus")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "nexus.federated.Nexus")
set(APPLICATION_ID "nexus.federated.nexus")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.

View file

@ -11,6 +11,7 @@
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
#include <window_size/window_size_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar =
@ -28,4 +29,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
g_autoptr(FlPluginRegistrar) window_size_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin");
window_size_plugin_register_with_registrar(window_size_registrar);
}

View file

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
screen_retriever_linux
url_launcher_linux
window_manager
window_size
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -1,9 +0,0 @@
[Desktop Entry]
Name=Nexus
GenericName=Matrix Client
Comment=A simple and user-friendly Matrix client
Exec=nexus
Icon=nexus
Terminal=false
Type=Application
Categories=Chat;Network;InstantMessaging;

View file

@ -1,41 +0,0 @@
{ pkgs, lib }:
let
android = pkgs.androidenv.composeAndroidPackages {
toolsVersion = "26.1.1";
platformToolsVersion = "36.0.1";
buildToolsVersions = [
"35.0.0"
"36.0.0"
];
cmakeVersions = [ "3.22.1" ];
platformVersions = [ "36" ];
abiVersions = [
"armeabi-v7a"
"arm64-v8a"
];
includeNDK = true;
ndkVersions = [ "28.2.13676358" ];
};
in
pkgs.mkShell {
packages = with pkgs; [
go
git
jdk17
flutter
android.platform-tools
];
env = rec {
LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ];
LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}";
CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ];
ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
ANDROID_SDK_ROOT = ANDROID_HOME;
JAVA_HOME = pkgs.jdk17;
TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}";
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2";
};
}

View file

@ -1,44 +0,0 @@
{
lib,
callPackage,
libclang,
flutter,
src,
}:
flutter.buildFlutterApplication {
pname = "nexus";
version = "0.1.0";
inherit src;
preBuild = ''
cp ${callPackage ./gomuks.nix { inherit src; }}/lib/* .
packageRunCustom nexus generate source/scripts test
packageRun build_runner build
'';
env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ];
autoPubspecLock = src + "/pubspec.lock";
gitHashes = {
window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM=";
dynamic_system_colors = "sha256-es6rjMK1drkqZBKYUP77yw/q5+0uLwWOEDOXRawy3Dc=";
flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk=";
flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk=";
};
postInstall = ''
install -D assets/icon.svg $out/share/icons/hicolor/scalable/apps/nexus.svg
install -Dm755 linux/nexus.federated.Nexus.desktop -t $out/share/applications
wrapProgram $out/bin/nexus \
--suffix LD_LIBRARY_PATH : $out/app/nexus/lib
'';
meta = {
description = "A simple and user-friendly Matrix client";
mainProgram = "nexus";
platforms = lib.platforms.linux;
maintainers = with lib.maintainers; [ quadradical ];
};
}

View file

@ -1,31 +0,0 @@
{
src,
buildGoModule,
}:
buildGoModule (finalAttrs: {
pname = "gomuks-ffi";
version = "submodule";
doCheck = false;
src = "${src}/gomuks";
vendorHash = "sha256-zBDfBZqUoHIfZ0AajZEvSBbskjpFB7yIsomt0KYDo7Y=";
buildPhase = ''
runHook preBuild
go build -buildmode=c-shared -o libgomuks.so -tags goolm,noheic ./pkg/ffi
runHook postBuild
'';
installPhase = ''
runHook preInstall
install -Dm0644 libgomuks.so -t $out/lib
runHook postInstall
'';
})

View file

@ -43,7 +43,6 @@ static void my_application_activate(GApplication* application) {
}
}
#endif
gtk_widget_set_size_request(GTK_WIDGET(window), 500, 500);
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));

20
nix/android.nix Normal file
View file

@ -0,0 +1,20 @@
{
androidenv,
}:
androidenv.composeAndroidPackages {
toolsVersion = "26.1.1";
platformToolsVersion = "36.0.1";
buildToolsVersions = [
"35.0.0"
"36.0.0"
];
cmakeVersions = [ "3.22.1" ];
platformVersions = [ "36" ];
abiVersions = [
"armeabi-v7a"
"arm64-v8a"
];
includeNDK = true;
ndkVersions = [ "27.0.12077973" ];
}

View file

@ -29,10 +29,10 @@ packages:
dependency: transitive
description:
name: analyzer_buffer
sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1"
sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033
url: "https://pub.dev"
source: hosted
version: "0.3.1"
version: "0.1.11"
analyzer_plugin:
dependency: transitive
description:
@ -348,11 +348,10 @@ packages:
dynamic_system_colors:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "3b61760d5e0ac1229eefde5b61247947eede4110"
url: "https://github.com/hasali19/flutter_dynamic_system_colors"
source: git
name: dynamic_system_colors
sha256: "43794e658fa88cbdec9f397dd1afd2eb69b6c9717e99b93b16ba37c3aa3b3a8c"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
encrypt:
dependency: transitive
@ -522,10 +521,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.1.0"
flutter_svg:
dependency: "direct main"
description:
@ -644,10 +643,10 @@ packages:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "08527ec06aaef75e4b78694e045ef0cd8346594eaf9cc18b0f866398b07b93b1"
sha256: b880efcd17757af0aa242e5dceac2fb781a014c22a32435a5daa8f17e9d5d8a9
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.1.0"
html:
dependency: transitive
description:
@ -1052,26 +1051,26 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "3.1.0"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66
sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0"
url: "https://pub.dev"
source: hosted
version: "1.0.0-dev.9"
version: "1.0.0-dev.8"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f"
sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.1.0"
rxdart:
dependency: transitive
description:
@ -1549,6 +1548,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.1"
window_size:
dependency: "direct main"
description:
path: "plugins/window_size"
ref: HEAD
resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
url: "https://github.com/google/flutter-desktop-embedding"
source: git
version: "0.1.0"
xdg_directories:
dependency: transitive
description:

View file

@ -21,8 +21,8 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
flutter_riverpod: ^3.3.1
hooks_riverpod: ^3.3.1
flutter_riverpod: ^3.0.3
hooks_riverpod: ^3.0.3
intl: ^0.20.1
fast_immutable_collections: ^11.0.0
path_provider: ^2.1.3
@ -31,11 +31,13 @@ dependencies:
image_picker: ^1.1.2
file_picker: ^10.3.3
path: ^1.9.0
dynamic_system_colors:
git:
url: https://github.com/hasali19/flutter_dynamic_system_colors
dynamic_system_colors: ^1.8.0
collection: ^1.19.1
window_manager: ^0.5.1
window_size:
git:
url: https://github.com/google/flutter-desktop-embedding
path: plugins/window_size
flutter_chat_core: ^2.0.0
flyer_chat_image_message: ^2.2.2
flyer_chat_system_message: ^2.1.13
@ -67,7 +69,7 @@ dev_dependencies:
custom_lint: ^0.8.0
flutter_lints: ^6.0.0
freezed: ^3.2.3
riverpod_lint: ^3.1.3
riverpod_lint: ^3.0.3
flutter_launcher_icons: ^0.14.1
json_serializable: ^6.11.1
@ -75,7 +77,7 @@ flutter_launcher_icons:
ios: true
android: true
image_path: assets/icon.png
adaptive_icon_background: assets/background.png
adaptive_icon_background: "#000000"
adaptive_icon_foreground: assets/foreground.png
remove_alpha_ios: true
windows:

View file

@ -3,7 +3,26 @@ import "package:ffigen/ffigen.dart";
import "package:path/path.dart";
void main(List<String> args) async {
final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks"));
final repoDir = Directory.fromUri(
Platform.script.resolve("../src/gomuks/source"),
);
if (await repoDir.exists()) await repoDir.delete(recursive: true);
await repoDir.create(recursive: true);
print("Cloning Gomuks repository...");
final cloneResult = await Process.run("git", [
"clone",
"--depth",
"1",
"https://mau.dev/gomuks/gomuks",
repoDir.path,
]);
if (cloneResult.exitCode != 0) {
throw Exception(
"Failed to clone Gomuks repository: \n${cloneResult.stderr}",
);
}
print("Generating FFI Bindings...");

9
scripts/generate.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
pushd "$(dirname "$(readlink -f "$0")")"/.. > /dev/null || exit
mkdir -p build
touch build/lock
dart scripts/generate.dart
rm build/lock
popd > /dev/null || exit

View file

@ -11,6 +11,7 @@
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
#include <window_size/window_size_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
DynamicColorPluginCApiRegisterWithRegistrar(
@ -23,4 +24,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
WindowSizePluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowSizePlugin"));
}

View file

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
screen_retriever_windows
url_launcher_windows
window_manager
window_size
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -89,11 +89,11 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "nexus.federated.Nexus" "\0"
VALUE "CompanyName", "nexus.federated.nexus" "\0"
VALUE "FileDescription", "nexus" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "nexus" "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 nexus.federated.Nexus. All rights reserved." "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 nexus.federated.nexus. All rights reserved." "\0"
VALUE "OriginalFilename", "nexus.exe" "\0"
VALUE "ProductName", "nexus" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"