Compare commits

..

45 commits

Author SHA1 Message Date
Elec3137
d0df1a198b try to use generate script
the script fails with:
Couldn't resolve the package 'ffigen' in 'package:ffigen/ffigen.dart'.
Couldn't resolve the package 'path' in 'package:path/path.dart'.
2026-02-01 17:12:26 -08:00
Elec3137
cc050f62fc dubious (mostly style) changes in flake 2026-02-01 14:25:45 -08:00
Elec3137
f72c59f79d rename fromYAML -> importYAML
this is more consistent with existing nixpkgs.lib functions
2026-02-01 14:15:44 -08:00
Elec3137
32393f12c0 add packages.default flake output
this likely doesn't work (yet),
cannot properly test because at time of writing,
the app doesn't compile through `flutter run` either.
2026-02-01 13:39:16 -08:00
db2a169f34 Update README.md 2026-02-01 06:19:04 -05:00
82b6f2c647
joining rooms from matrix uri or plaintext 2026-01-30 20:22:51 +01:00
cafdf43fd3
removing unused imports because they annoy me 2026-01-30 19:04:03 +01:00
d62626858d
update readme 2026-01-30 18:47:13 +01:00
985b6d52e0
safety checks and message fix 2026-01-30 18:45:55 +01:00
2372ecd141
Working images 2026-01-30 18:45:55 +01:00
34f45c929a
fix bug in timeline 2026-01-30 18:45:55 +01:00
4f66bdc817
working member list 2026-01-30 18:45:55 +01:00
6cc5971919
Working mentions 2026-01-30 18:45:55 +01:00
75be47554f
add a todo 2026-01-30 18:45:55 +01:00
2878412573
show member count 2026-01-30 18:45:55 +01:00
07b492d0f4
wip mention overlay 2026-01-30 18:45:55 +01:00
132efbdfd1
working history loading 2026-01-30 18:45:55 +01:00
bfd0b1ec47
wip 2026-01-30 18:45:55 +01:00
0df327d125
wip thing 2026-01-30 18:45:55 +01:00
3d341ac4d8
working decryption 2026-01-30 18:45:55 +01:00
15ccddfad5
redact support 2026-01-30 18:45:55 +01:00
e59632bb07
working message rendering 2026-01-30 18:45:55 +01:00
5f96c8e57f
shows room but not really 2026-01-30 18:45:55 +01:00
7b0fea3a07
working sidebar 2026-01-30 18:45:55 +01:00
f51d773885
wip 2026-01-30 18:45:55 +01:00
6afa169af9
working sync complete 2026-01-30 18:45:55 +01:00
27fba2cdb5
wip 2026-01-30 18:45:55 +01:00
5424dda62a
Nicer verification error 2026-01-30 18:45:55 +01:00
e447540062
working login 2026-01-30 18:45:55 +01:00
7c6ddab6a3
working login page 2026-01-30 18:45:55 +01:00
5e0ba1029d
add unhandled event printer 2026-01-30 18:45:55 +01:00
96ca282d5a
fix up nix flake 2026-01-30 18:45:55 +01:00
8be9a4f2f6
kind of working! 2026-01-30 18:45:55 +01:00
d1c4a69b40
working build 2026-01-30 18:45:55 +01:00
5c6440894b
working build scripts 2026-01-30 18:45:55 +01:00
e8f4ff072d
working build of generated file 2026-01-30 18:45:55 +01:00
b2da534a32
update readme 2026-01-30 18:45:55 +01:00
ede26c9a27
add skip param 2026-01-30 18:45:30 +01:00
66d4ef18b4
fix build hook 2026-01-30 18:45:30 +01:00
362819b464
wip go 3 2026-01-30 18:45:30 +01:00
2a86bdafeb
wip go 2 2026-01-30 18:45:30 +01:00
77d9f9bdc1
wip go 2026-01-30 18:45:30 +01:00
32d5f30e03 Update README.md 2026-01-24 11:41:06 -05:00
23ff5b31b7
fix gitignore 2026-01-17 16:24:30 -05:00
e60e55bbbf
add note about moving to rust 2026-01-17 14:33:54 -05:00
144 changed files with 2905 additions and 46100 deletions

1
.gitignore vendored
View file

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

View file

@ -15,7 +15,7 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S
## Progress ## Progress
- [ ] Move from the Dart SDK to the Rust SDK with Dart bindings: Waiting on https://github.com/fzyzcjy/flutter_rust_bridge/discussions/2967#discussioncomment-15522205. - [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2
- [ ] Platform Support - [ ] Platform Support
- [x] Linux - [x] Linux
- [x] Windows - [x] Windows
@ -33,14 +33,17 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S
- [ ] Searching - [ ] Searching
- [ ] Creating (Rooms, Spaces, and DMs) - [ ] Creating (Rooms, Spaces, and DMs)
- [x] Joining - [x] Joining
- [x] Using alias/id/link - [x] Using a text/uri/link
- [x] Plain text
- [x] `matrix:` Uri
- [ ] Matrix.to link: I just need to fix my regex
- [ ] From space - [ ] From space
- [ ] Exploring - [ ] Exploring
- [x] Leaving - [x] Leaving
- [x] Subspaces - [x] Subspaces
- [x] Messages - [x] Messages
- [x] Encryption - [x] Encryption
- [ ] Restoring crypto identity from passphrase/key or verification - [x] Restoring crypto identity from passphrase/key or verification
- [x] Sending - [x] Sending
- [x] Plain text - [x] Plain text
- [x] HTML/Markdown - [x] HTML/Markdown
@ -147,4 +150,4 @@ flutter run
## Community ## Community
Come chat in the [Federated Nexus Community](https://matrix.to/#/#space:federated.nexus) for questions or help with developing or using Nexus Client. Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.

View file

@ -1,6 +1,7 @@
analyzer: analyzer:
errors: errors:
invalid_annotation_target: ignore invalid_annotation_target: ignore
avoid_print: ignore
exclude: exclude:
- "build/**" - "build/**"
- "**/*.g.dart" - "**/*.g.dart"

6
build.yaml Normal file
View file

@ -0,0 +1,6 @@
targets:
$default:
builders:
json_serializable:
options:
field_rename: snake

View file

@ -1,3 +0,0 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View file

@ -23,52 +23,73 @@
perSystem = perSystem =
{ {
lib,
pkgs, pkgs,
system, system,
... ...
}: }:
let
src = ./.;
# from https://discourse.nixos.org/t/is-there-a-way-to-read-a-yaml-file-and-get-back-a-set/18385/5
importYAML =
file:
lib.importJSON (
pkgs.runCommand "converted-yaml.json" { } ''${pkgs.yj}/bin/yj < "${file}" > "$out"''
);
package = importYAML "${src}/pubspec.yaml";
usedFlutter = (pkgs.flutter.override { extraPkgConfigPackages = [ pkgs.libsecret ]; });
buildInputs = [ pkgs.sqlite ];
in
{ {
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
config = { config = {
permittedInsecurePackages = [ "olm-3.2.16" ];
android_sdk.accept_license = true; android_sdk.accept_license = true;
allowUnfree = true; allowUnfree = true;
}; };
}; };
devShells.default = devShells.default = pkgs.mkShell {
let
# android = pkgs.callPackage ./nix/android.nix { };
in
pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
# jdk17 go
cargo olm
rustc git
openssl_3 clang
pkg-config usedFlutter
(flutter.override { extraPkgConfigPackages = [ pkgs.libsecret ]; })
flutter_rust_bridge_codegen
# android.platform-tools
(pkgs.writeShellScriptBin "rustup" (builtins.readFile ./nix/fake-rustup.sh)) (pkgs.writeShellScriptBin "rustup" (builtins.readFile ./nix/fake-rustup.sh))
]; ];
env = rec { env = {
LD_LIBRARY_PATH = "${ LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}:./build/native_assets/linux";
pkgs.lib.makeLibraryPath ([ CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ];
pkgs.sqlite
])
}:./build/linux/x64/debug/plugins/flutter_vodozemac:./build/linux/x64/debug/plugins/rust_lib_nexus";
# 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";
}; };
}; };
packages.default = usedFlutter.buildFlutterApplication {
inherit src buildInputs;
pname = package.name;
version = package.version;
pubspecLock = importYAML "${src}/pubspec.lock";
gitHashes = {
flutter_chat_ui = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk=";
flutter_link_previewer = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk=";
flyer_chat_text_message = "sha256-4fuag7lRH5cMBFD3fUzj2K541JwXLoz8HF/4OMr3uhk=";
window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM=";
};
patchPhase = /* sh */ ''
patchShebangs ./scripts/generate.sh
./scripts/generate.sh # note: this doesn't quit the build on fail
'';
};
}; };
}; };
} }

View file

@ -1,3 +0,0 @@
rust_input: matrix-sdk
rust_root: rust/
dart_output: lib/src/rust

54
hook/build.dart Normal file
View file

@ -0,0 +1,54 @@
import "dart:io";
import "package:hooks/hooks.dart";
import "package:code_assets/code_assets.dart";
Future<void> main(List<String> args) => build(args, (input, output) async {
final buildDir = input.packageRoot.resolve("src/");
if (await File(buildDir.resolve("lock").toFilePath()).exists()) return;
final targetOS = input.config.code.targetOS;
String libFileName;
switch (targetOS) {
case OS.linux:
libFileName = "libgomuks.so";
break;
case OS.macOS:
libFileName = "libgomuks.dylib";
break;
case OS.windows:
libFileName = "libgomuks.dll";
break;
default:
throw UnsupportedError("Unsupported OS: $targetOS");
}
final gomuksBuildDir = buildDir.resolve("gomuks/");
final libFile = gomuksBuildDir.resolve(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());
if (result.exitCode != 0) {
throw Exception("Failed to build Gomuks shared library\n${result.stderr}");
}
final generatedFile = "src/third_party/gomuks.g.dart";
print("Adding $libFileName as asset...");
output
..assets.code.add(
CodeAsset(
package: "nexus",
name: generatedFile,
linkMode: DynamicLoadingBundled(),
file: libFile,
),
)
..dependencies.add(libFile)
..dependencies.add(gomuksBuildDir);
print("Done!");
});

View file

@ -1,17 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/client_controller.dart";
class AvatarController extends AsyncNotifier<Uri> {
final String mxc;
AvatarController(this.mxc);
@override
Future<Uri> build() async => Uri.parse(mxc).getThumbnailUri(
await ref.watch(ClientController.provider.future),
width: 24,
height: 24,
);
static final provider = AsyncNotifierProvider.family
.autoDispose<AvatarController, Uri, String>(AvatarController.new);
}

View file

@ -1,106 +1,213 @@
import "dart:convert"; import "dart:developer";
import "dart:io"; import "dart:ffi";
import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:matrix/encryption.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/database_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:vodozemac/vodozemac.dart" as vod; import "package:nexus/controllers/space_edges_controller.dart";
import "package:flutter_vodozemac/flutter_vodozemac.dart" as fl_vod; import "package:nexus/controllers/sync_status_controller.dart";
import "package:matrix/matrix.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/paginate.dart";
import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/models/requests/get_related_events_request.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/join_room_request.dart";
import "package:nexus/models/requests/login_request.dart";
import "package:nexus/models/profile.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/requests/report_request.dart";
import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/models/sync_status.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/secure_storage_controller.dart";
import "package:nexus/models/session_backup.dart";
class ClientController extends AsyncNotifier<Client> { class ClientController extends AsyncNotifier<int> {
@override @override
bool updateShouldNotify( Future<int> build() async {
AsyncValue<Client> previous, final handle = await Isolate.run(GomuksInit);
AsyncValue<Client> next,
) =>
previous.hasValue != next.hasValue ||
previous.value?.accessToken != next.value?.accessToken;
static const sessionBackupKey = "sessionBackup";
@override final callable =
Future<Client> build() async { NativeCallable<
if (!vod.isInitialized()) fl_vod.init(); Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
final client = Client( >.listener((
"nexus", Pointer<Char> command,
logLevel: kReleaseMode ? Level.warning : Level.verbose, int requestId,
importantStateEvents: {"im.ponies.room_emotes"}, GomuksOwnedBuffer data,
supportedLoginTypes: {AuthenticationTypes.password}, ) {
verificationMethods: {KeyVerificationMethod.emoji},
database: await MatrixSdkDatabase.init(
"nexus",
database: await ref.watch(DatabaseController.provider.future),
),
nativeImplementations: NativeImplementationsIsolate(
compute,
vodozemacInit: fl_vod.init,
),
);
final backupJson = await ref
.watch(SecureStorageController.provider.notifier)
.get(sessionBackupKey);
if (backupJson != null) {
final backup = SessionBackup.fromJson(json.decode(backupJson));
await client.init(
waitForFirstSync: false,
newToken: backup.accessToken,
newHomeserver: backup.homeserver,
newUserID: backup.userID,
newDeviceID: backup.deviceID,
newDeviceName: backup.deviceName,
);
}
return client;
}
Future<bool> setHomeserver(Uri homeserverUrl) async {
final client = await future;
try { try {
await client.checkHomeserver(homeserverUrl); final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJson();
switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(ClientState.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(SyncStatus.fromJson(decodedMuksEvent));
break;
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider;
if (syncData.clearState) ref.invalidate(roomProvider);
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
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;
case "typing":
//TODO: IMPL
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
debugger();
debugPrintStack(stackTrace: stackTrace, label: error.toString());
}
});
ref.onDispose(() => GomuksDestroy(handle));
ref.onDispose(callable.close);
final errorCode = GomuksStart(handle, callable.nativeFunction);
if (errorCode == 0) return handle;
throw Exception("GomuksStart returned error code $errorCode");
}
Future<dynamic> _sendCommand(
String command,
Map<String, dynamic> data,
) 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 (json is String) throw json;
return json;
}
Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson());
Future<void> sendMessage(SendMessageRequest request) =>
_sendCommand("send_message", request.toJson());
Future<bool> verify(String recoveryKey) async {
try {
await _sendCommand("verify", {"recovery_key": recoveryKey});
return true; return true;
} catch (_) { } catch (error) {
return false; return false;
} }
} }
Future<bool> login(String username, String password) async { Future<String> joinRoom(JoinRoomRequest request) async {
final client = await future; 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});
}
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
final response =
(await _sendCommand("get_room_state", request.toJson())) as List;
return response.map((event) => Event.fromJson(event)).toIList();
}
Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request,
) async {
final response =
(await _sendCommand("get_related_events", request.toJson())) as List?;
return response?.map((event) => Event.fromJson(event)).toIList();
}
Future<Event?> getEvent(GetEventRequest request) async {
final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : Event.fromJson(json);
}
Future<Paginate> paginate(PaginateRequest request) async =>
Paginate.fromJson(await _sendCommand("paginate", request.toJson()));
Future<Profile> getProfile(String userId) async =>
Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
Future<void> reportEvent(ReportRequest report) =>
_sendCommand("report_event", report.toJson());
Future<void> markRead(Room room) async {
if (room.events.isEmpty || room.metadata == null) return;
await _sendCommand("mark_read", {
"room_id": room.metadata?.id,
"receipt_type": "m.read",
"event_id": room.events.last.eventId,
});
}
Future<bool> login(LoginRequest login) async {
try { try {
final deviceName = "Nexus Client login on ${Platform.localHostname}"; await _sendCommand("login", login.toJson());
final details = await MatrixApi(homeserver: client.homeserver).login(
LoginType.mLoginPassword,
initialDeviceDisplayName: deviceName,
identifier: AuthenticationUserIdentifier(user: username),
password: password,
);
await ref
.watch(SecureStorageController.provider.notifier)
.set(
sessionBackupKey,
json.encode(
SessionBackup(
accessToken: details.accessToken,
homeserver: client.homeserver!,
userID: details.userId,
deviceID: details.deviceId,
deviceName: deviceName,
).toJson(),
),
);
ref.invalidateSelf(asReload: true);
return true; return true;
} catch (_) { } catch (error) {
return false; return false;
} }
} }
static final provider = AsyncNotifierProvider<ClientController, Client>( Future<String?> discoverHomeserver(Uri homeserver) async {
try {
final response = await _sendCommand("discover_homeserver", {
"user_id": "@fakeuser:${homeserver.host}",
});
return response["m.homeserver"]?["base_url"];
} catch (error) {
return null;
}
}
static final provider = AsyncNotifierProvider<ClientController, int>(
ClientController.new, ClientController.new,
); );
} }

View file

@ -0,0 +1,15 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/client_state.dart";
class ClientStateController extends Notifier<ClientState?> {
@override
Null build() => null;
void set(ClientState newState) {
state = newState;
}
static final provider = NotifierProvider<ClientStateController, ClientState?>(
ClientStateController.new,
);
}

View file

@ -1,18 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path/path.dart";
import "package:path_provider/path_provider.dart";
import "package:sqflite_common_ffi/sqflite_ffi.dart";
class DatabaseController extends AsyncNotifier<Database> {
@override
Future<Database> build() async {
databaseFactory = databaseFactoryFfi;
return databaseFactoryFfi.openDatabase(
join((await getApplicationSupportDirectory()).path, "database.db"),
);
}
static final provider = AsyncNotifierProvider<DatabaseController, Database>(
DatabaseController.new,
);
}

View file

@ -1,18 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class EventsController extends AsyncNotifier<Timeline> {
EventsController(this.room);
final Room room;
@override
Future<Timeline> build({String? from}) => room.getTimeline();
Future<void> prev() async {
final timeline = await future;
await timeline.requestHistory();
}
static final provider = AsyncNotifierProvider.autoDispose
.family<EventsController, Timeline, Room>(EventsController.new);
}

View file

@ -0,0 +1,27 @@
import "dart:ffi";
import "dart:isolate";
import "package:ffi/ffi.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
class HeaderController extends AsyncNotifier<Map<String, String>> {
@override
Future<Map<String, String>> build() async {
final handle = await ref.watch(ClientController.provider.future);
final info = await Isolate.run(() => GomuksGetAccountInfo(handle));
final headers = {
if (info.access_token != nullptr)
"authorization":
"Bearer ${info.access_token.cast<Utf8>().toDartString()}",
};
await Isolate.run(() => GomuksFreeAccountInfo(info));
return headers;
}
static final provider =
AsyncNotifierProvider<HeaderController, Map<String, String>>(
HeaderController.new,
);
}

View file

@ -1,22 +1,27 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart";
class MembersController extends AsyncNotifier<IList<MatrixEvent>> { class MembersController extends AsyncNotifier<IList<Event>> {
final Room room; final Room room;
MembersController(this.room); MembersController(this.room);
@override @override
Future<IList<MatrixEvent>> build() async => IList( Future<IList<Event>> build() async =>
(await room.client.getMembersByRoom( (room.state["m.room.member"]?.values ?? [])
room.id, .map(
notMembership: Membership.leave, (eventRowId) => room.events.firstWhereOrNull(
)) ?? (event) => event.rowId == eventRowId,
[], ),
); )
.nonNulls
.where((member) => member.content["membership"] == "join")
.toIList();
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<MembersController, IList<MatrixEvent>, Room>( .autoDispose<MembersController, IList<Event>, Room>(
MembersController.new, MembersController.new,
); );
} }

View file

@ -0,0 +1,189 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/profile_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/message_config.dart";
import "package:nexus/models/requests/get_event_request.dart";
import "package:nexus/models/requests/get_related_events_request.dart";
class MessageController extends AsyncNotifier<Message?> {
final MessageConfig config;
MessageController(this.config);
@override
Future<Message?> build() async {
if (config.event.relationType == "m.replace" && !config.includeEdits) {
return null;
}
final client = ref.watch(ClientController.provider.notifier);
final newEvents = await client.getRelatedEvents(
GetRelatedEventsRequest(
roomId: config.event.roomId,
eventId: config.event.eventId,
relationType: "m.replace",
),
);
if (!ref.mounted) return null;
final event = newEvents?.lastOrNull ?? config.event;
final replyId =
config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"];
final replyEvent = replyId == null
? null
: await client.getEvent(
GetEventRequest(roomId: config.event.roomId, eventId: replyId),
);
if (!ref.mounted) return null;
final author = await ref.read(
ProfileController.provider(event.authorId).future,
);
if (!ref.mounted) return null;
final content = (event.decrypted ?? event.content);
final type = (config.event.decryptedType ?? config.event.type);
final newContent = content["m.new_content"] as Map?;
final metadata = {
"timelineId": event.timelineRowId,
"formatted":
newContent?["formatted_body"] ??
newContent?["body"] ??
content["formatted_body"] ??
content["body"] ??
"",
if (replyEvent != null)
"reply": await ref.read(
MessageController.provider(
MessageConfig(event: replyEvent, mustBeText: true),
).future,
),
"body": newContent?["body"] ?? content["body"],
"eventType": type,
"avatarUrl": author.avatarUrl,
"displayName": author.displayName ?? event.authorId,
"txnId": config.event.transactionId,
};
if (!ref.mounted) return null;
final editedAt = event.relationType == "m.replace" ? event.timestamp : null;
if ((event.redactedBy != null && !config.mustBeText) ||
(!config.includeEdits && (config.event.relationType == "m.replace"))) {
return null;
}
// TODO: Use server-generated preview if enabled
// final match = Uri.tryParse(
// RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "",
// );
final asText =
Message.text(
metadata: metadata,
id: config.event.eventId,
authorId: event.authorId,
text: config.event.redactedBy == null
? content["body"] ?? ""
: "This message has been deleted...",
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
editedAt: editedAt,
)
as TextMessage;
if (config.mustBeText) return asText;
final homeserver = ref.read(ClientStateController.provider)?.homeserverUrl;
final source = homeserver == null || content["url"] == null
? "null"
: Uri.parse(content["url"]).mxcToHttps(homeserver).toString();
return switch (type) {
"m.room.encrypted" => asText.copyWith(
text: "Unable to decrypt message.",
metadata: {...metadata, "formatted": "Unable to decrypt message."},
),
// "org.matrix.msc3381.poll.start" => Message.custom(
// metadata: {
// ...metadata,
// "poll": event.parsedPollEventContent.pollStartContent,
// "responses": event.getPollResponses(timeline),
// },
// id: eventId,
// deliveredAt: originServerTs,
// authorId: senderId,
// ),
("m.sticker" || "m.room.message") => switch (content["msgtype"]) {
("m.sticker" || "m.image") => Message.image(
id: config.event.eventId,
metadata: metadata,
authorId: event.authorId,
text: event.localContent?.sanitizedHtml,
source: source,
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"],
),
"m.audio" => Message.audio(
id: config.event.eventId,
metadata: metadata,
authorId: event.authorId,
text: content["body"],
replyToMessageId: replyId,
source: source,
deliveredAt: config.event.timestamp,
// TODO: See if we can figure out duration
duration: Duration(hours: 1),
),
"m.file" => Message.file(
name: content["filename"].toString(),
metadata: metadata,
id: config.event.eventId,
authorId: event.authorId,
source: source,
replyToMessageId: replyId,
deliveredAt: config.event.timestamp,
),
_ => asText,
},
"m.room.member" => Message.system(
metadata: metadata,
id: config.event.eventId,
authorId: event.authorId,
deliveredAt: config.event.timestamp,
text:
"${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) {
"invite" => "was invited to",
"join" => "joined",
"leave" => "left",
"knock" => "asked to join",
"ban" => "was banned from",
_ => "did something relating to",
}} the room.",
),
"m.room.redaction" => null,
_ =>
// Turn this on for debugging purposes
false
// ignore: dead_code
? Message.unsupported(
metadata: metadata,
id: config.event.eventId,
authorId: event.authorId,
replyToMessageId: replyId,
)
: null,
};
}
static final provider = AsyncNotifierProvider.family
.autoDispose<MessageController, Message?, MessageConfig>(
MessageController.new,
);
}

View file

@ -0,0 +1,25 @@
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/event.dart";
import "package:nexus/models/message_config.dart";
class MessagesController extends AsyncNotifier<IList<Message>> {
final IList<Event> events;
MessagesController(this.events);
@override
Future<IList<Message>> build() async => (await Future.wait(
events.map(
(event) => ref.watch(
MessageController.provider(MessageConfig(event: event)).future,
),
),
)).nonNulls.toIList();
static final provider = AsyncNotifierProvider.family
.autoDispose<MessagesController, IList<Message>, IList<Event>>(
MessagesController.new,
);
}

View file

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

View file

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

View file

@ -0,0 +1,17 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/profile.dart";
class ProfileController extends AsyncNotifier<Profile> {
final String userId;
ProfileController(this.userId);
@override
Future<Profile> build() =>
ref.watch(ClientController.provider.notifier).getProfile(userId);
static final provider =
AsyncNotifierProvider.family<ProfileController, Profile, String>(
ProfileController.new,
);
}

View file

@ -1,43 +1,72 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/avatar_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/events_controller.dart"; import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extensions/event_to_message.dart"; import "package:nexus/controllers/messages_controller.dart";
import "package:nexus/helpers/extensions/list_to_messages.dart"; import "package:nexus/controllers/new_events_controller.dart";
import "package:fluttertagger/fluttertagger.dart" as tagger; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/models/message_config.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
import "package:nexus/models/requests/paginate_request.dart";
import "package:nexus/models/requests/redact_event_request.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
final Room room; final String roomId;
RoomChatController(this.room); RoomChatController(this.roomId);
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
final timeline = await ref.watch(EventsController.provider(room).future); final client = ref.watch(ClientController.provider.notifier);
final room = ref.read(SelectedRoomController.provider);
if (room == null) return InMemoryChatController();
final messages = await ref.watch(
MessagesController.provider(
room.timeline
.map(
(timelineRowTuple) => room.events.firstWhereOrNull(
(event) => event.rowId == timelineRowTuple.eventRowId,
),
)
.nonNulls
.toIList(),
).future,
);
final controller = InMemoryChatController(messages: messages.toList());
ref.onDispose( ref.onDispose(
room.client.onTimelineEvent.stream.listen((event) async { ref.listen(NewEventsController.provider(roomId), (_, next) async {
if (event.roomId != room.id) return; final controller = await future;
for (final event in next) {
if (event.type == EventTypes.Redaction) { if (event.type == "m.room.redaction") {
final controller = await future; final controller = await future;
final message = controller.messages.firstWhereOrNull( final message = controller.messages.firstWhereOrNull(
(message) => message.id == event.redacts, (message) => message.id == event.content["redacts"],
); );
if (message == null) return; if (message == null || !ref.mounted) return;
await controller.removeMessage(message); await controller.removeMessage(message);
} else { } else {
final message = await event.toMessage(includeEdits: true, timeline); final message = await ref.watch(
if (event.relationshipType == RelationshipTypes.edit) { MessageController.provider(
MessageConfig(event: event, includeEdits: true),
).future,
);
if (event.relationType == "m.replace") {
final controller = await future; final controller = await future;
final oldMessage = controller.messages.firstWhereOrNull( final oldMessage = controller.messages.firstWhereOrNull(
(element) => element.id == event.relationshipEventId, (element) => element.id == event.relatesTo,
); );
if (oldMessage == null || message == null) return; if (oldMessage == null || message == null || !ref.mounted) return;
return await updateMessage( return await updateMessage(
oldMessage, oldMessage,
message.copyWith( message.copyWith(
@ -45,23 +74,62 @@ class RoomChatController extends AsyncNotifier<ChatController> {
replyToMessageId: oldMessage.replyToMessageId, replyToMessageId: oldMessage.replyToMessageId,
metadata: { metadata: {
...(oldMessage.metadata ?? {}), ...(oldMessage.metadata ?? {}),
...((message.metadata ?? {}).filterMap( ...(message.metadata ?? {})
(key, value) => value == null ? null : MapEntry(key, value), .toIMap()
)), .where((key, value) => value != null)
.unlock,
}, },
), ),
); );
} }
if (message != null) { if (message != null &&
return await insertMessage(message); !controller.messages.any(
(oldMessage) => oldMessage.id == message.id,
) &&
ref.mounted) {
await controller.insertMessage(message);
} }
} }
}).cancel, }
}, weak: true).close,
); );
return InMemoryChatController( ref.onDispose(controller.dispose);
messages: await timeline.events.toMessages(room, timeline),
if (messages.length < 20) await loadOlder(controller);
final state = await client.getRoomState(
GetRoomStateRequest(
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList == false,
includeMembers: true,
),
); );
ref
.watch(RoomsController.provider.notifier)
.update(
{
roomId: Room(
events: state,
state: state.fold(
const IMap.empty(),
(previousValue, stateEvent) => previousValue.add(
stateEvent.type,
(previousValue[stateEvent.type] ?? const IMap.empty()).addAll(
IMap({
if (stateEvent.stateKey != null)
stateEvent.stateKey!: stateEvent.rowId,
}),
),
),
),
),
}.toIMap(),
const ISet.empty(),
);
return controller;
} }
Future<void> insertMessage(Message message) async { Future<void> insertMessage(Message message) async {
@ -81,35 +149,61 @@ class RoomChatController extends AsyncNotifier<ChatController> {
Future<void> deleteMessage(Message message, {String? reason}) async { Future<void> deleteMessage(Message message, {String? reason}) async {
final controller = await future; final controller = await future;
await controller.removeMessage(message); await controller.removeMessage(message);
await room.redactEvent(message.id, reason: reason); await ref
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: message.id,
roomId: roomId,
reason: reason,
),
);
} }
Future<void> loadOlder() async { Future<void> loadOlder([InMemoryChatController? chatController]) async {
final currentEvents = await future; final controller = chatController ?? await future;
await ref.watch(EventsController.provider(room).notifier).prev(); final client = ref.watch(ClientController.provider.notifier);
final timeline = await ref.watch(EventsController.provider(room).future);
final controller = await future; final response = await client.paginate(
await controller.insertAllMessages( PaginateRequest(
await timeline.events roomId: roomId,
.where( maxTimelineId: controller.messages.firstOrNull?.metadata?["timelineId"],
(event) => !currentEvents.messages.any( ),
(existingEvent) => existingEvent.id == event.eventId, );
ref
.watch(RoomsController.provider.notifier)
.update(
IMap({
roomId: Room(
events: response.events.addAll(response.relatedEvents),
hasMore: response.hasMore,
timeline: response.events
.map(
(event) => TimelineRowTuple(
timelineRowId: event.timelineRowId,
eventRowId: event.rowId,
), ),
) )
.toList() .toIList(),
.toMessages(room, timeline), ),
}),
const ISet.empty(),
);
final messages = await ref.watch(
MessagesController.provider(response.events.reversed).future,
);
await controller.insertAllMessages(
messages
.where(
(newMessage) => !controller.messages.any(
(message) => message.id == newMessage.id,
),
)
.toList(),
index: 0, index: 0,
); );
ref.notifyListeners();
}
Future<void> markRead() async {
if (!room.hasNewMessages) return;
final controller = await future;
final id = controller.messages.last.id;
await room.setReadMarker(id, mRead: id);
} }
Future<void> updateMessage(Message message, Message newMessage) async => Future<void> updateMessage(Message message, Message newMessage) async =>
@ -117,7 +211,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
Future<void> send( Future<void> send(
String message, { String message, {
required Iterable<tagger.Tag> tags, required Iterable<Tag> tags,
required RelationType relationType, required RelationType relationType,
Message? relation, Message? relation,
}) async { }) async {
@ -133,30 +227,42 @@ class RoomChatController extends AsyncNotifier<ChatController> {
); );
} }
await room.sendTextEvent( final client = ref.watch(ClientController.provider.notifier);
taggedMessage, client.sendMessage(
editEventId: relationType == RelationType.edit ? relation?.id : null, SendMessageRequest(
inReplyTo: (relationType == RelationType.reply && relation != null) roomId: roomId,
? await room.getEventById(relation.id) mentions: Mentions(
: null, userIds: [
if (relation != null && relationType == RelationType.reply)
relation.authorId,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
relation: relation == null
? null
: Relation(eventId: relation.id, relationType: relationType),
),
); );
} }
Future<chat.User> resolveUser(String id) async { Future<chat.User> resolveUser(String id) async {
final user = await room.client.getUserProfile(id); final user = await ref
.watch(ClientController.provider.notifier)
.getProfile(id);
return chat.User( return chat.User(
id: id, id: id,
name: user.displayname, name: user.displayName,
imageSource: user.avatarUrl == null // imageSource: user.avatarUrl == null
? null // ? null
: (await ref.watch( // : (await ref.watch(
AvatarController.provider(user.avatarUrl!.toString()).future, // AvatarController.provider(user.avatarUrl!.toString()).future,
)).toString(), // )).toString(),
); );
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, ChatController, Room>( .autoDispose<RoomChatController, ChatController, String>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -1,23 +1,83 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/helpers/extensions/get_full_room.dart"; import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/full_room.dart"; import "package:nexus/models/room.dart";
class RoomsController extends AsyncNotifier<IList<FullRoom>> { class RoomsController extends Notifier<IMap<String, Room>> {
@override @override
Future<IList<FullRoom>> build() async { IMap<String, Room> build() => const IMap.empty();
final client = await ref.watch(ClientController.provider.future);
ref.onDispose( void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key;
final incoming = entry.value;
final existing = acc[roomId];
final events = existing?.events.updateById(
incoming.events,
(item) => item.eventId,
); );
return IList(await Future.wait(client.rooms.map((room) => room.fullRoom))); ref
.watch(NewEventsController.provider(roomId).notifier)
.add(
incoming.timeline
.map(
(timelineTuple) => events?.firstWhereOrNull(
(event) => timelineTuple.eventRowId == event.rowId,
),
)
.nonNulls
.toIList(),
);
return acc.add(
roomId,
existing?.copyWith(
metadata: incoming.metadata ?? existing.metadata,
events: events!,
state: incoming.state.entries.fold(
existing.state,
(previousValue, event) => previousValue.add(
event.key,
(previousValue[event.key] ?? const IMap.empty()).addAll(
event.value,
),
),
),
timeline:
(incoming.reset
? incoming.timeline
: existing.timeline.updateById(
incoming.timeline,
(item) => item.timelineRowId,
))
.sortedBy((element) => element.timelineRowId)
.toIList(),
receipts: incoming.receipts.entries.fold(
existing.receipts,
(receiptAcc, event) => receiptAcc.add(
event.key,
(receiptAcc[event.key] ?? IList<ReadReceipt>()).addAll(
event.value,
),
),
),
) ??
incoming,
);
});
final prunedList = leftRooms.fold(
merged,
(acc, roomId) => acc.remove(roomId),
);
state = prunedList;
} }
static final provider = static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
AsyncNotifierProvider<RoomsController, IList<FullRoom>>(
RoomsController.new, RoomsController.new,
); );
} }

View file

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

View file

@ -4,12 +4,10 @@ import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/models/space.dart"; import "package:nexus/models/space.dart";
class SelectedSpaceController extends AsyncNotifier<Space> { class SelectedSpaceController extends Notifier<Space> {
@override @override
Future<Space> build() async { Space build() {
final spaces = await ref.watch( final spaces = ref.watch(SpacesController.provider);
SpacesController.provider.selectAsync((data) => data),
);
final selectedSpaceId = ref.watch( final selectedSpaceId = ref.watch(
KeyController.provider(KeyController.spaceKey), KeyController.provider(KeyController.spaceKey),
); );
@ -18,7 +16,7 @@ class SelectedSpaceController extends AsyncNotifier<Space> {
spaces.first; spaces.first;
} }
static final provider = AsyncNotifierProvider<SelectedSpaceController, Space>( static final provider = NotifierProvider<SelectedSpaceController, Space>(
SelectedSpaceController.new, SelectedSpaceController.new,
); );
} }

View file

@ -0,0 +1,15 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/space_edge.dart";
class SpaceEdgesController extends Notifier<IMap<String, IList<SpaceEdge>>> {
@override
IMap<String, IList<SpaceEdge>> build() => const IMap.empty();
void set(IMap<String, IList<SpaceEdge>> newEdges) => state = newEdges;
static final provider =
NotifierProvider<SpaceEdgesController, IMap<String, IList<SpaceEdge>>>(
SpaceEdgesController.new,
);
}

View file

@ -1,77 +1,96 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/helpers/extensions/get_full_room.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/helpers/extensions/room_to_children.dart"; import "package:nexus/controllers/space_edges_controller.dart";
import "package:nexus/models/space.dart"; import "package:nexus/models/space.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space_edge.dart";
class SpacesController extends AsyncNotifier<IList<Space>> { class SpacesController extends Notifier<IList<Space>> {
@override @override
Future<IList<Space>> build() async { IList<Space> build() {
final client = await ref.watch(ClientController.provider.future); final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
ref.onDispose( final childRoomsBySpaceId = IMap.fromEntries(
client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, topLevelSpaceIds.map((spaceId) {
); ISet<String> walk(String currentId) {
final children = spaceEdges[currentId] ?? IList<SpaceEdge>();
final topLevel = IList( return children.fold<ISet<String>>(const ISet.empty(), (acc, edge) {
await Future.wait( final childId = edge.childId;
client.rooms final isSpace = spaceEdges.containsKey(childId);
.where((room) => !room.isDirectChat)
.where(
(room) => client.rooms
.where((room) => room.isSpace)
.every(
(match) => match.spaceChildren.every(
(child) => child.roomId != room.id,
),
),
)
.map((room) => room.fullRoom),
),
);
final topLevelSpaces = topLevel.where((r) => r.roomData.isSpace).toIList(); return acc
final topLevelRooms = topLevel.where((r) => !r.roomData.isSpace).toIList(); .addAll(!isSpace ? ISet([childId]) : const ISet.empty())
.addAll(isSpace ? walk(childId) : const ISet.empty());
return IList([ });
Space(
client: client,
title: "Home",
id: "home",
children: topLevelRooms,
icon: Icons.home,
),
Space(
client: client,
title: "Direct Messages",
id: "dms",
children: IList(
await Future.wait(
client.rooms
.where((room) => room.isDirectChat)
.map((room) => room.fullRoom),
),
),
icon: Icons.person,
),
...(await Future.wait(
topLevelSpaces.map(
(space) async => Space(
client: client,
title: space.title,
avatar: space.avatar,
id: space.roomData.id,
roomData: space.roomData,
children: IList(await space.roomData.getAllChildren()),
),
),
)),
]);
} }
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>( return MapEntry(
spaceId,
walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(),
);
}),
);
final allNestedRoomIds = childRoomsBySpaceId.values
.expand((l) => l)
.map(
(room) =>
rooms.entries.firstWhere((entry) => entry.value == room).key,
)
.toISet();
final otherRooms = rooms.entries
.where(
(e) =>
!allNestedRoomIds.contains(e.key) &&
!topLevelSpaceIds.contains(e.key) &&
!spaceEdges.containsKey(e.key),
)
.map((e) => e.value);
final homeRooms = otherRooms
.where((room) => room.metadata?.dmUserId == null)
.toIList();
final dmRooms = otherRooms
.where((room) => room.metadata?.dmUserId != null)
.toIList();
final topLevelSpacesList = topLevelSpaceIds
.map((id) {
final room = rooms[id];
if (room == null) return null;
final children = childRoomsBySpaceId[id] ?? IList<Room>();
return Space(
id: id,
title: room.metadata?.name ?? "Unnamed Room",
room: room,
children: children,
);
})
.nonNulls
.toIList();
return <Space>[
Space(id: "home", title: "Home", icon: Icons.home, children: homeRooms),
Space(
id: "dms",
title: "Direct Messages",
icon: Icons.people,
children: dmRooms,
),
...topLevelSpacesList,
].toIList();
}
static final provider = NotifierProvider<SpacesController, IList<Space>>(
SpacesController.new, SpacesController.new,
); );
} }

View file

@ -0,0 +1,13 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/sync_status.dart";
class SyncStatusController extends Notifier<SyncStatus?> {
@override
Null build() => null;
void set(SyncStatus newStatus) => state = newStatus;
static final provider = NotifierProvider<SyncStatusController, SyncStatus?>(
SyncStatusController.new,
);
}

View file

@ -1,22 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/image_data.dart";
class ThumbnailController extends AsyncNotifier<String?> {
ThumbnailController(this.data);
final ImageData data;
@override
Future<String?> build({String? from}) async {
final client = await ref.watch(ClientController.provider.future);
final uri = await Uri.tryParse(data.uri)?.getDownloadUri(client);
return uri.toString();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<ThumbnailController, String?, ImageData>(
ThumbnailController.new,
);
}

View file

@ -0,0 +1,14 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class TopLevelSpacesController extends Notifier<IList<String>> {
@override
IList<String> build() => const IList.empty();
void set(IList<String> newSpaces) => state = newSpaces;
static final provider =
NotifierProvider<TopLevelSpacesController, IList<String>>(
TopLevelSpacesController.new,
);
}

View file

@ -1,145 +0,0 @@
import "package:collection/collection.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart";
extension EventToMessage on Event {
Future<Message?> toMessage(
Timeline timeline, {
bool mustBeText = false,
bool includeEdits = false,
}) async {
final replyId = inReplyToEventId();
final newEvent = (unsigned?["m.relations"] as Map?)?["m.replace"];
final event = newEvent == null ? this : Event.fromJson(newEvent, room);
final replyEvent = replyId == null
? null
: await room.getEventById(replyId);
final sender =
await event.fetchSenderUser() ?? event.senderFromMemoryOrFallback;
final newContent = event.content["m.new_content"] as Map?;
final metadata = {
"formatted":
newContent?["formatted_body"] ??
newContent?["body"] ??
event.content["formatted_body"] ??
event.content["body"] ??
"",
"reply": await replyEvent?.toMessage(mustBeText: true, timeline),
"body": newContent?["body"] ?? event.content["body"],
"eventType": event.type,
"avatarUrl": sender.avatarUrl.toString(),
"displayName": sender.displayName ?? sender.id,
"txnId": transactionId,
};
final editedAt = event.relationshipType == RelationshipTypes.edit
? event.originServerTs
: null;
if ((redacted && !mustBeText) ||
(!includeEdits && (relationshipType == RelationshipTypes.edit))) {
return null;
}
// TODO: Use server-generated preview if enabled when https://github.com/famedly/matrix-dart-sdk/issues/2195 is fixed.
// final match = Uri.tryParse(
// RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "",
// );
// final preview = match == null
// ? null
// : await room.client.getUrlPreview(match);
final asText =
Message.text(
metadata: metadata,
id: eventId,
authorId: senderId,
text: redacted ? "This message has been deleted..." : event.body,
replyToMessageId: replyId,
deliveredAt: originServerTs,
editedAt: editedAt,
)
as TextMessage;
if (mustBeText) return asText;
return switch (type) {
EventTypes.Encrypted => asText.copyWith(
text: "Unable to decrypt message.",
metadata: {...metadata, "formatted": "Unable to decrypt message."},
),
PollEventContent.startType => Message.custom(
metadata: {
...metadata,
"poll": event.parsedPollEventContent.pollStartContent,
"responses": event.getPollResponses(timeline),
},
id: eventId,
deliveredAt: originServerTs,
authorId: senderId,
),
(EventTypes.Sticker || EventTypes.Message) => switch (messageType) {
(MessageTypes.Sticker || MessageTypes.Image) => Message.image(
metadata: metadata,
id: eventId,
authorId: senderId,
text: event.text,
source: (await getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: originServerTs,
blurhash: (event.content["info"] as Map?)?["xyz.amorgan.blurhash"],
),
MessageTypes.Audio => Message.audio(
metadata: metadata,
id: eventId,
authorId: senderId,
text: event.text,
replyToMessageId: replyId,
source: (await event.getAttachmentUri()).toString(),
deliveredAt: originServerTs,
// TODO: See if we can figure out duration
duration: Duration(hours: 1),
),
MessageTypes.File => Message.file(
name: event.content["filename"].toString(),
metadata: metadata,
id: eventId,
authorId: senderId,
source: (await event.getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: originServerTs,
),
_ => asText,
},
EventTypes.RoomMember => Message.system(
metadata: metadata,
id: eventId,
authorId: senderId,
text:
"${event.asUser.displayName ?? event.asUser.id} ${switch (Membership.values.firstWhereOrNull((membership) => membership.name == event.content["membership"])) {
Membership.invite => "was invited to",
Membership.join => "joined",
Membership.leave => "left",
Membership.knock => "asked to join",
Membership.ban => "was banned from",
_ => "did something relating to",
}} the room.",
),
EventTypes.Redaction => null,
_ =>
// Turn this on for debugging purposes
false
// ignore: dead_code
? Message.unsupported(
metadata: metadata,
id: eventId,
authorId: senderId,
replyToMessageId: replyId,
)
: null,
};
}
}

View file

@ -1,13 +0,0 @@
import "package:matrix/matrix.dart";
import "package:nexus/models/full_room.dart";
extension GetFullRoom on Room {
Future<FullRoom> get fullRoom async {
await loadHeroUsers();
return FullRoom(
roomData: this,
title: getLocalizedDisplayname(),
avatar: await avatar?.getThumbnailUri(client, width: 24, height: 24),
);
}
}

View file

@ -1,5 +1,7 @@
import "package:matrix/matrix.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/header_controller.dart";
extension GetHeaders on Client { extension GetHeaders on WidgetRef {
Map<String, String> get headers => {"authorization": "Bearer $accessToken"}; Map<String, String> get headers =>
watch(HeaderController.provider).requireValue;
} }

View file

@ -0,0 +1,36 @@
import "dart:convert";
import "dart:ffi";
import "dart:typed_data";
import "package:ffi/ffi.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
Uint8List toBytes() {
try {
if (base == nullptr || length <= 0) return Uint8List(0);
return Uint8List.fromList(base.asTypedList(length));
} finally {
calloc.free(base);
}
}
dynamic toJson() => jsonDecode(utf8.decode(toBytes()));
}
extension JsonToGomuksBuffer on Map<String, dynamic> {
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
final jsonString = json.encode(this);
final bytes = utf8.encode(jsonString);
final dataPtr = calloc<Uint8>(bytes.length);
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
final ptr = calloc<GomuksBorrowedBuffer>();
ptr.ref
..base = dataPtr
..length = bytes.length;
return ptr;
}
}

View file

@ -1,42 +1,47 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/models/requests/join_room_request.dart";
extension JoinRoomWithSnackbars on Client { extension JoinRoomWithSnackbars on ClientController {
Future<void> joinRoomWithSnackBars( Future<void> joinRoomWithSnackBars(
BuildContext context, BuildContext context,
String roomAlias, String roomAlias,
WidgetRef ref, WidgetRef ref,
) async { ) async {
final parsed = roomAlias.parseIdentifierIntoParts(); final roomIdOrAlias = roomAlias.mention ?? roomAlias;
final alias = parsed?.primaryIdentifier ?? roomAlias;
final scaffoldMessenger = ScaffoldMessenger.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context);
final snackbar = scaffoldMessenger.showSnackBar( final snackbar = scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text("Joining room $alias."), content: Text("Joining room $roomIdOrAlias."),
duration: Duration(days: 999), duration: Duration(days: 999),
), ),
); );
try { try {
final id = await joinRoom(alias, via: parsed?.via.toList()); final id = await joinRoom(
JoinRoomRequest(
roomIdOrAlias: roomIdOrAlias,
via: IList(Uri.tryParse(roomAlias)?.queryParametersAll["via"] ?? []),
),
);
snackbar.close(); snackbar.close();
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text("Room $alias successfully joined."), content: Text("Room $roomIdOrAlias successfully joined."),
action: SnackBarAction( action: SnackBarAction(
label: "Open", label: "Open",
onPressed: () async { onPressed: () async {
final spaces = await ref.refresh( final spaces = ref.watch(SpacesController.provider);
SpacesController.provider.future,
);
final space = spaces.firstWhereOrNull((space) => space.id == id); final space = spaces.firstWhereOrNull((space) => space.id == id);
await ref await ref
@ -47,11 +52,9 @@ extension JoinRoomWithSnackbars on Client {
space?.id ?? space?.id ??
spaces spaces
.firstWhere( .firstWhere(
(space) => (space) => space.children.any(
space.children.firstWhereOrNull( (child) => child.metadata?.id == id,
(child) => child.roomData.id == id, ),
) !=
null,
) )
.id, .id,
); );

View file

@ -0,0 +1,44 @@
extension LinkToMention on String {
/// Extracts a Matrix identifier from this string.
///
/// Supports:
/// - https://matrix.to/#/...
/// - matrix:roomid/...
/// - matrix:r/...
/// - matrix:u/...
///
/// Returns the decoded identifier (e.g. "#room:matrix.org")
/// or null if this is not a Matrix link.
String? get mention {
final trimmed = trim();
final matrixTo = RegExp(
r"^https?://matrix\.to/#/([^/?#]+)",
caseSensitive: false,
);
final matrixToMatch = matrixTo.firstMatch(trimmed);
if (matrixToMatch != null) {
return Uri.decodeComponent(matrixToMatch.group(1)!);
}
if (trimmed.toLowerCase().startsWith("matrix:")) {
try {
final uri = Uri.parse(trimmed);
if (uri.pathSegments.isNotEmpty) {
final identifier = uri.pathSegments.last;
if (identifier.isNotEmpty) {
return "${switch (uri.pathSegments.firstOrNull) {
"r" || "roomid" => "#",
"u" => "@",
_ => "",
}}${Uri.decodeComponent(identifier)}";
}
}
} catch (_) {}
}
return null;
}
}

View file

@ -1,10 +0,0 @@
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart";
import "package:nexus/helpers/extensions/event_to_message.dart";
extension ListToMessages on List<MatrixEvent> {
Future<List<Message>> toMessages(Room room, Timeline timeline) async =>
(await Future.wait(
map((event) => Event.fromMatrixEvent(event, room).toMessage(timeline)),
)).nonNulls.toList().reversed.toList();
}

View file

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

View file

@ -1,27 +0,0 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:matrix/matrix.dart";
import "package:nexus/helpers/extensions/get_full_room.dart";
import "package:nexus/models/full_room.dart";
extension RoomToChildren on Room {
Future<IList<FullRoom>> getAllChildren() async {
final direct = await Future.wait(
spaceChildren
.map(
(child) => client.rooms
.firstWhereOrNull((r) => r.id == child.roomId)
?.fullRoom,
)
.nonNulls,
);
return (await Future.wait(
direct.map(
(child) async => child.roomData.isSpace
? await child.roomData.getAllChildren()
: [child],
),
)).expand((list) => list).toIList();
}
}

View file

@ -1,15 +1,18 @@
import "dart:developer";
import "dart:io"; import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/header_controller.dart";
import "package:nexus/controllers/multi_provider_controller.dart";
import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/controllers/shared_prefs_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/pages/chat_page.dart"; import "package:nexus/pages/chat_page.dart";
import "package:nexus/pages/login_page.dart"; import "package:nexus/pages/login_page.dart";
import "package:nexus/pages/settings_page.dart"; import "package:nexus/pages/verify_page.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
@ -35,11 +38,13 @@ New Value: ${newValue is AsyncData ? newValue.value : newValue}
void showError(Object error, [StackTrace? stackTrace]) { void showError(Object error, [StackTrace? stackTrace]) {
if (error.toString().contains("DioException")) return; if (error.toString().contains("DioException")) return;
if (error.toString().contains("Invalid source")) return;
if (error.toString().contains("UTF-16")) return; if (error.toString().contains("UTF-16")) return;
if (error.toString().contains("HTTP request failed")) return; if (error.toString().contains("HTTP request failed")) return;
if (error.toString().contains("Invalid image data")) return; if (error.toString().contains("Invalid image data")) return;
debugPrintStack(stackTrace: stackTrace, label: error.toString()); debugPrintStack(stackTrace: stackTrace, label: error.toString());
debugger();
if (navigatorKey.currentContext != null) { if (navigatorKey.currentContext != null) {
Future.delayed( Future.delayed(
Duration.zero, Duration.zero,
@ -81,11 +86,11 @@ void main() async {
); );
} }
class App extends ConsumerWidget { class App extends StatelessWidget {
const App({super.key}); const App({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => DynamicColorBuilder( Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => MaterialApp( builder: (lightDynamic, darkDynamic) => MaterialApp(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
@ -99,39 +104,36 @@ class App extends ConsumerWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
)) ))
.theme, .theme,
home: Builder( home: Scaffold(
builder: (context) => ref body: Consumer(
.watch(SharedPrefsController.provider) builder: (_, ref, _) => ref
.watch(
MultiProviderController.provider(
IListConst([
SharedPrefsController.provider,
ClientController.provider,
HeaderController.provider,
]),
),
)
.betterWhen( .betterWhen(
data: (_) => ref data: (_) => Consumer(
.watch(ClientController.provider) builder: (_, ref, _) {
.betterWhen( final clientState = ref.watch(
data: (client) => ClientStateController.provider,
client.accessToken == null ? LoginPage() : ChatPage(), );
loading: () => Scaffold( if (clientState == null || !clientState.isInitialized) {
body: Center( return Loading();
child: Column( }
mainAxisSize: MainAxisSize.min,
spacing: 16, if (!clientState.isLoggedIn) {
children: [ return LoginPage();
Text( } else if (!clientState.isVerified) {
"Syncing...", return VerifyPage();
style: Theme.of(context).textTheme.headlineMedium, } else {
), return ChatPage();
Loading(), }
], },
),
),
appBar: Appbar(
actions: [
IconButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => SettingsPage()),
),
icon: Icon(Icons.settings),
),
],
),
), ),
), ),
), ),

View file

@ -0,0 +1,17 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "client_state.freezed.dart";
part "client_state.g.dart";
@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

@ -0,0 +1,11 @@
import "package:freezed_annotation/freezed_annotation.dart";
class EpochDateTimeConverter implements JsonConverter<DateTime, int> {
const EpochDateTimeConverter();
@override
DateTime fromJson(int json) => DateTime.fromMillisecondsSinceEpoch(json);
@override
int toJson(DateTime object) => object.millisecondsSinceEpoch;
}

79
lib/models/event.dart Normal file
View file

@ -0,0 +1,79 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
part "event.freezed.dart";
part "event.g.dart";
@freezed
abstract class Event with _$Event {
const factory Event({
@JsonKey(name: "rowid") required int rowId,
@JsonKey(name: "timeline_rowid") required int timelineRowId,
required String roomId,
required String eventId,
@JsonKey(name: "sender") required String authorId,
required String type,
String? stateKey,
@EpochDateTimeConverter() required DateTime timestamp,
required IMap<String, dynamic> content,
IMap<String, dynamic>? decrypted,
String? decryptedType,
@Default(IMap.empty()) IMap<String, dynamic> unsigned,
LocalContent? localContent,
String? transactionId,
String? redactedBy,
String? relatesTo,
String? relationType,
String? decryptionError,
String? sendError,
@Default(IMap.empty()) IMap<String, int> reactions,
int? lastEditRowId,
@UnreadTypeConverter() UnreadType? unreadType,
}) = _Event;
factory Event.fromJson(Map<String, Object?> json) => _$EventFromJson(json);
}
@freezed
abstract class LocalContent with _$LocalContent {
const factory LocalContent({
String? sanitizedHtml,
bool? wasPlaintext,
bool? bigEmoji,
bool? hasMath,
bool? replyFallbackRemoved,
}) = _LocalContent;
factory LocalContent.fromJson(Map<String, Object?> json) =>
_$LocalContentFromJson(json);
}
class UnreadTypeConverter implements JsonConverter<UnreadType?, int?> {
const UnreadTypeConverter();
@override
UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json);
@override
int? toJson(UnreadType? object) => object?.value;
}
// I think this is correct but I'm not sure, its some type of bitmask.
@immutable
class UnreadType {
final int value;
const UnreadType(this.value);
static const none = UnreadType(0);
static const normal = UnreadType(1);
static const notify = UnreadType(2);
static const highlight = UnreadType(4);
static const sound = UnreadType(8);
bool get isNone => value == 0;
bool get isNormal => (value & 1) != 0;
bool get shouldNotify => (value & 2) != 0;
bool get isHighlighted => (value & 4) != 0;
bool get playsSound => (value & 8) != 0;
}

View file

@ -1,13 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:matrix/matrix.dart";
part "full_room.freezed.dart";
@freezed
abstract class FullRoom with _$FullRoom {
const FullRoom._();
const factory FullRoom({
required Room roomData,
required String title,
required Uri? avatar,
}) = _FullRoom;
}

View file

@ -1,11 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "image_data.freezed.dart";
@freezed
abstract class ImageData with _$ImageData {
const factory ImageData({
required String uri,
required int? height,
required int? width,
}) = _ImageData;
}

View file

@ -0,0 +1,16 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "lazy_load_summary.freezed.dart";
part "lazy_load_summary.g.dart";
@freezed
abstract class LazyLoadSummary with _$LazyLoadSummary {
const factory LazyLoadSummary({
required IList<String>? heroes,
required int? joinedMemberCount,
required int? invitedMemberCount,
}) = _LazyLoadSummary;
factory LazyLoadSummary.fromJson(Map<String, Object?> json) =>
_$LazyLoadSummaryFromJson(json);
}

View file

@ -0,0 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "message_config.freezed.dart";
part "message_config.g.dart";
@freezed
abstract class MessageConfig with _$MessageConfig {
const factory MessageConfig({
@Default(false) bool mustBeText,
@Default(false) bool includeEdits,
required Event event,
}) = _MessageConfig;
factory MessageConfig.fromJson(Map<String, Object?> json) =>
_$MessageConfigFromJson(json);
}

17
lib/models/paginate.dart Normal file
View file

@ -0,0 +1,17 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "paginate.freezed.dart";
part "paginate.g.dart";
@freezed
abstract class Paginate with _$Paginate {
const factory Paginate({
required IList<Event> events,
required IList<Event> relatedEvents,
required bool hasMore,
}) = _Paginate;
factory Paginate.fromJson(Map<String, Object?> json) =>
_$PaginateFromJson(json);
}

29
lib/models/profile.dart Normal file
View file

@ -0,0 +1,29 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "profile.freezed.dart";
part "profile.g.dart";
@freezed
abstract class Profile with _$Profile {
const factory Profile({
String? avatarUrl,
@JsonKey(name: "displayname") String? displayName,
@JsonKey(name: "us.cloke.msc4175.tz") String? timezone,
@Default(IList.empty())
@JsonKey(name: "io.fsky.nyx.pronouns")
IList<Pronoun> pronouns,
}) = _Profile;
factory Profile.fromJson(Map<String, Object?> json) =>
_$ProfileFromJson(json);
}
@freezed
abstract class Pronoun with _$Pronoun {
const factory Pronoun({required String language, required String summary}) =
_Pronoun;
factory Pronoun.fromJson(Map<String, Object?> json) =>
_$PronounFromJson(json);
}

View file

@ -0,0 +1,18 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
part "read_receipt.freezed.dart";
part "read_receipt.g.dart";
@freezed
abstract class ReadReceipt with _$ReadReceipt {
const factory ReadReceipt({
String? roomId,
required String userId,
String? threadId,
required String eventId,
@EpochDateTimeConverter() required DateTime timestamp,
}) = _ReadReceipt;
factory ReadReceipt.fromJson(Map<String, Object?> json) =>
_$ReadReceiptFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_event_request.freezed.dart";
part "get_event_request.g.dart";
@freezed
abstract class GetEventRequest with _$GetEventRequest {
const factory GetEventRequest({
required String roomId,
required String eventId,
@Default(false) bool unredact,
}) = _GetEventRequest;
factory GetEventRequest.fromJson(Map<String, Object?> json) =>
_$GetEventRequestFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_related_events_request.freezed.dart";
part "get_related_events_request.g.dart";
@freezed
abstract class GetRelatedEventsRequest with _$GetRelatedEventsRequest {
const factory GetRelatedEventsRequest({
required String roomId,
required String eventId,
required String relationType,
}) = _GetRelatedEventsRequest;
factory GetRelatedEventsRequest.fromJson(Map<String, Object?> json) =>
_$GetRelatedEventsRequestFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_room_state_request.freezed.dart";
part "get_room_state_request.g.dart";
@freezed
abstract class GetRoomStateRequest with _$GetRoomStateRequest {
const factory GetRoomStateRequest({
required String roomId,
required bool fetchMembers,
@Default(false) bool includeMembers,
}) = _GetRoomStateRequest;
factory GetRoomStateRequest.fromJson(Map<String, Object?> json) =>
_$GetRoomStateRequestFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "join_room_request.freezed.dart";
part "join_room_request.g.dart";
@freezed
abstract class JoinRoomRequest with _$JoinRoomRequest {
const factory JoinRoomRequest({
required String roomIdOrAlias,
required IList<String> via,
}) = _JoinRoomRequest;
factory JoinRoomRequest.fromJson(Map<String, Object?> json) =>
_$JoinRoomRequestFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "login_request.freezed.dart";
part "login_request.g.dart";
@freezed
abstract class LoginRequest with _$LoginRequest {
const factory LoginRequest({
required String username,
required String password,
required String homeserverUrl,
}) = _LoginRequest;
factory LoginRequest.fromJson(Map<String, Object?> json) =>
_$LoginRequestFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "paginate_request.freezed.dart";
part "paginate_request.g.dart";
@freezed
abstract class PaginateRequest with _$PaginateRequest {
const factory PaginateRequest({
required String roomId,
required int? maxTimelineId,
@Default(20) int limit,
}) = _PaginateRequest;
factory PaginateRequest.fromJson(Map<String, Object?> json) =>
_$PaginateRequestFromJson(json);
}

View file

@ -0,0 +1,3 @@
import "package:nexus/models/requests/report_request.dart";
typedef RedactEventRequest = ReportRequest;

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "report_request.freezed.dart";
part "report_request.g.dart";
@freezed
abstract class ReportRequest with _$ReportRequest {
const factory ReportRequest({
required String roomId,
required String eventId,
String? reason,
}) = _ReportRequest;
factory ReportRequest.fromJson(Map<String, Object?> json) =>
_$ReportRequestFromJson(json);
}

View file

@ -0,0 +1,54 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/relation_type.dart";
part "send_message_request.freezed.dart";
part "send_message_request.g.dart";
@freezed
abstract class SendMessageRequest with _$SendMessageRequest {
const factory SendMessageRequest({
required String roomId,
required String text,
@Default(Mentions()) @JsonKey(name: "mentions") Mentions mentions,
@JsonKey(name: "relates_to") Relation? relation,
}) = _SendMessageRequest;
factory SendMessageRequest.fromJson(Map<String, Object?> json) =>
_$SendMessageRequestFromJson(json);
}
@freezed
abstract class Mentions with _$Mentions {
const factory Mentions({
@Default(false) bool room,
@Default(IList.empty()) IList<String> userIds,
}) = _Mentions;
factory Mentions.fromJson(Map<String, Object?> json) =>
_$MentionsFromJson(json);
}
@Freezed(toJson: false)
abstract class Relation with _$Relation {
const Relation._();
const factory Relation({
required String eventId,
required RelationType relationType,
}) = _Relation;
Map<String, dynamic> toJson() {
switch (relationType) {
case RelationType.reply:
return {
"m.in_reply_to": {"event_id": eventId},
};
case RelationType.edit:
return {"rel_type": "m.replace", "event_id": eventId};
}
}
factory Relation.fromJson(Map<String, dynamic> json) =>
_$RelationFromJson(json);
}

36
lib/models/room.dart Normal file
View file

@ -0,0 +1,36 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room_metadata.dart";
part "room.freezed.dart";
part "room.g.dart";
@freezed
abstract class Room with _$Room {
const factory Room({
@JsonKey(name: "meta") RoomMetadata? metadata,
@Default(IList.empty()) IList<TimelineRowTuple> timeline,
@Default(false) bool reset,
@Default(IMap.empty()) IMap<String, IMap<String, int>> state,
// required IMap<String, AccountData> accountData,
@Default(IList.empty()) IList<Event> events,
@Default(IMap.empty()) IMap<String, IList<ReadReceipt>> receipts,
@Default(false) bool dismissNotifications,
@Default(true) bool hasMore,
// required IList<Notification> notifications,
}) = _Room;
factory Room.fromJson(Map<String, Object?> json) => _$RoomFromJson(json);
}
@freezed
abstract class TimelineRowTuple with _$TimelineRowTuple {
const factory TimelineRowTuple({
@JsonKey(name: "timeline_rowid") required int timelineRowId,
@JsonKey(name: "event_rowid") int? eventRowId,
}) = _TimelineRowTuple;
factory TimelineRowTuple.fromJson(Map<String, Object?> json) =>
_$TimelineRowTupleFromJson(json);
}

View file

@ -0,0 +1,30 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
import "package:nexus/models/lazy_load_summary.dart";
part "room_metadata.freezed.dart";
part "room_metadata.g.dart";
@freezed
abstract class RoomMetadata with _$RoomMetadata {
const factory RoomMetadata({
@JsonKey(name: "room_id") required String id,
// required CreateEventContent creationContent,
// required TombstoneEventContent tombstoneEventContent,
String? name,
Uri? avatar,
String? dmUserId,
String? topic,
String? canonicalAlias,
LazyLoadSummary? lazyLoadSummary,
required bool hasMemberList,
@JsonKey(name: "preview_event_rowid") required int previewEventRowID,
@EpochDateTimeConverter() required DateTime sortingTimestamp,
required int unreadHighlights,
required int unreadNotifications,
required int unreadMessages,
}) = _RoomMetadata;
factory RoomMetadata.fromJson(Map<String, Object?> json) =>
_$RoomMetadataFromJson(json);
}

View file

@ -1,17 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "session_backup.freezed.dart";
part "session_backup.g.dart";
@freezed
abstract class SessionBackup with _$SessionBackup {
const factory SessionBackup({
required String accessToken,
required Uri homeserver,
required String userID,
required String deviceID,
required String deviceName,
}) = _SessionBackup;
factory SessionBackup.fromJson(Map<String, Object?> json) =>
_$SessionBackupFromJson(json);
}

View file

@ -1,20 +1,16 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:matrix/matrix.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/full_room.dart";
part "space.freezed.dart"; part "space.freezed.dart";
@freezed @freezed
abstract class Space with _$Space { abstract class Space with _$Space {
const Space._();
const factory Space({ const factory Space({
required String title,
required String id, required String id,
required IList<FullRoom> children, required String title,
required Client client,
Room? roomData,
Uri? avatar,
IconData? icon, IconData? icon,
Room? room,
required IList<Room> children,
}) = _Space; }) = _Space;
} }

View file

@ -0,0 +1,14 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "space_edge.freezed.dart";
part "space_edge.g.dart";
@freezed
abstract class SpaceEdge with _$SpaceEdge {
const factory SpaceEdge({
required String childId,
@Default(false) bool suggested,
}) = _SpaceEdge;
factory SpaceEdge.fromJson(Map<String, Object?> json) =>
_$SpaceEdgeFromJson(json);
}

22
lib/models/sync_data.dart Normal file
View file

@ -0,0 +1,22 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space_edge.dart";
part "sync_data.freezed.dart";
part "sync_data.g.dart";
@freezed
abstract class SyncData with _$SyncData {
const factory SyncData({
@Default(false) bool clearState,
// required IMap<String, AccountData> accountData,
@Default(IMap.empty()) IMap<String, Room> rooms,
@Default(ISet.empty()) ISet<String> leftRooms,
// required IList<InvitedRoom> invitedRooms,
IMap<String, IList<SpaceEdge>>? spaceEdges,
IList<String>? topLevelSpaces,
}) = _SyncData;
factory SyncData.fromJson(Map<String, Object?> json) =>
_$SyncDataFromJson(json);
}

View file

@ -0,0 +1,18 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "sync_status.freezed.dart";
part "sync_status.g.dart";
@freezed
abstract class SyncStatus with _$SyncStatus {
const factory SyncStatus({
required SyncStatusType type,
String? error,
required int errorCount,
}) = _SyncStatus;
factory SyncStatus.fromJson(Map<String, Object?> json) =>
_$SyncStatusFromJson(json);
}
@JsonEnum(fieldRename: FieldRename.snake)
enum SyncStatusType { ok, waiting, erroring, permanentlyFailed }

View file

@ -1,12 +1,13 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/widgets/chat_page/room_chat.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/chat_page/sidebar.dart"; import "package:nexus/widgets/chat_page/sidebar.dart";
import "package:nexus/widgets/chat_page/room_chat.dart";
class ChatPage extends StatelessWidget { class ChatPage extends ConsumerWidget {
const ChatPage({super.key}); const ChatPage({super.key});
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650; final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000; final showMembersByDefault = constraints.maxWidth > 1000;

View file

@ -5,6 +5,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart"; import "package:nexus/models/homeserver.dart";
import "package:nexus/models/requests/login_request.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -15,27 +16,25 @@ class LoginPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final client = ref.watch(ClientController.provider.notifier);
final isLoading = useState(false); final isLoading = useState(false);
final allowLogin = useState(false); final homeserver = useState<String?>(null);
final launch = ref.watch(LaunchHelper.provider).launchUrl; final launch = ref.watch(LaunchHelper.provider).launchUrl;
Future<void> setHomeserver(Uri? homeserver) async { Future<void> setHomeserver(Uri? newHomeserver) async {
isLoading.value = true; isLoading.value = true;
final succeeded = homeserver == null
? false homeserver.value = newHomeserver == null
: await ref ? null
.watch(ClientController.provider.notifier) : await client.discoverHomeserver(
.setHomeserver( newHomeserver.hasScheme
homeserver.hasScheme ? newHomeserver
? homeserver : Uri.https(newHomeserver.path),
: Uri.https(homeserver.path),
); );
if (succeeded) { if (homeserver.value == null && context.mounted) {
allowLogin.value = true;
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -157,7 +156,7 @@ class LoginPage extends HookConsumerWidget {
), ),
if (isLoading.value) if (isLoading.value)
Padding(padding: EdgeInsets.only(top: 32), child: Loading()) Padding(padding: EdgeInsets.only(top: 32), child: Loading())
else if (allowLogin.value) ...[ else if (homeserver.value != null) ...[
DividerText("Then, sign in:"), DividerText("Then, sign in:"),
SizedBox(height: 4), SizedBox(height: 4),
TextField( TextField(
@ -174,9 +173,13 @@ class LoginPage extends HookConsumerWidget {
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
isLoading.value = true; isLoading.value = true;
final succeeded = await ref final succeeded = await client.login(
.watch(ClientController.provider.notifier) LoginRequest(
.login(username.text, password.text); username: username.text,
password: password.text,
homeserverUrl: homeserver.value!,
),
);
if (!succeeded && context.mounted) { if (!succeeded && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(

View file

@ -0,0 +1,82 @@
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/form_text_input.dart";
class VerifyPage extends HookConsumerWidget {
const VerifyPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final passphraseController = useTextEditingController();
final isVerifying = useState(false);
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

@ -1,4 +1,5 @@
import "dart:io"; import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
@ -7,7 +8,7 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
final Widget? title; final Widget? title;
final Color? backgroundColor; final Color? backgroundColor;
final double? scrolledUnderElevation; final double? scrolledUnderElevation;
final List<Widget> actions; final IList<Widget> actions;
const Appbar({ const Appbar({
super.key, super.key,
@ -15,7 +16,7 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
this.backgroundColor, this.backgroundColor,
this.scrolledUnderElevation, this.scrolledUnderElevation,
this.leading, this.leading,
this.actions = const [], this.actions = const IList.empty(),
}); });
@override @override

View file

@ -1,28 +1,34 @@
import "package:color_hash/color_hash.dart"; import "package:color_hash/color_hash.dart";
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
class AvatarOrHash extends StatelessWidget { class AvatarOrHash extends ConsumerWidget {
final Uri? avatar; final Uri? avatar;
final String title; final String title;
final Widget? fallback; final Widget? fallback;
final bool hasBadge; final bool hasBadge;
final int badgeNumber;
final double height; final double height;
final Map<String, String> headers;
const AvatarOrHash( const AvatarOrHash(
this.avatar, this.avatar,
this.title, { this.title, {
this.fallback, this.fallback,
this.badgeNumber = 0,
this.hasBadge = false, this.hasBadge = false,
this.height = 24, this.height = 24,
required this.headers,
super.key, super.key,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final box = ColoredBox( final box = ColoredBox(
color: ColorHash(title).color, color: ColorHash(title).color,
child: Center(child: Text(title[0])), child: Center(child: Text(title.isEmpty ? "" : title[0])),
); );
return SizedBox( return SizedBox(
width: height, width: height,
@ -30,6 +36,7 @@ class AvatarOrHash extends StatelessWidget {
child: Center( child: Center(
child: Badge( child: Badge(
isLabelVisible: hasBadge, isLabelVisible: hasBadge,
label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null,
smallSize: 12, smallSize: 12,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
child: ClipRRect( child: ClipRRect(
@ -39,9 +46,21 @@ class AvatarOrHash extends StatelessWidget {
height: height, height: height,
child: avatar == null child: avatar == null
? fallback ?? box ? fallback ?? box
: Image.network( : Image(
avatar.toString(), image: CachedNetworkImage(
headers: headers, avatar!
.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
),
) ??
"",
)
.toString(),
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, _, _) => box, errorBuilder: (_, _, _) => box,
), ),

View file

@ -5,9 +5,9 @@ import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:fluttertagger/fluttertagger.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/chat_page/mention_overlay.dart"; import "package:nexus/widgets/chat_page/mention_overlay.dart";
import "package:nexus/widgets/chat_page/relation_preview.dart"; import "package:nexus/widgets/chat_page/relation_preview.dart";
@ -45,9 +45,9 @@ class ChatBox extends HookConsumerWidget {
} }
void send() { void send() {
if (controller.value.text.trim().isEmpty) return; if (controller.value.text.trim().isEmpty || room.metadata == null) return;
ref ref
.watch(RoomChatController.provider(room).notifier) .watch(RoomChatController.provider(room.metadata!.id).notifier)
.send( .send(
controller.value.formattedText, controller.value.formattedText,
relation: relatedMessage, relation: relatedMessage,
@ -94,7 +94,6 @@ class ChatBox extends HookConsumerWidget {
relatedMessage: relatedMessage, relatedMessage: relatedMessage,
relationType: relationType, relationType: relationType,
onDismiss: onDismiss, onDismiss: onDismiss,
room: room,
), ),
Container( Container(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
@ -105,7 +104,7 @@ class ChatBox extends HookConsumerWidget {
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [], itemBuilder: (context) => [],
icon: Icon(Icons.add), icon: Icon(Icons.add),
enabled: room.canSendDefaultMessages, // enabled: room.canSendDefaultMessages, TODO: Permissions check
), ),
Expanded( Expanded(
child: FlutterTagger( child: FlutterTagger(
@ -126,13 +125,13 @@ class ChatBox extends HookConsumerWidget {
}, },
triggerCharacterAndStyles: {"@": style, "#": style}, triggerCharacterAndStyles: {"@": style, "#": style},
builder: (context, key) => TextFormField( builder: (context, key) => TextFormField(
enabled: room.canSendDefaultMessages, // enabled: room.canSendDefaultMessages,
maxLines: 12, maxLines: 12,
minLines: 1, minLines: 1,
decoration: InputDecoration( decoration: InputDecoration(
hintText: room.canSendDefaultMessages // hintText: room.canSendDefaultMessages
? "Your message here..." // ? "Your message here..."
: "You don't have permission to send messages in this room...", // : "You don't have permission to send messages in this room...",
border: InputBorder.none, border: InputBorder.none,
), ),
controller: controller.value, controller: controller.value,
@ -143,7 +142,8 @@ class ChatBox extends HookConsumerWidget {
), ),
), ),
IconButton( IconButton(
onPressed: room.canSendDefaultMessages ? send : null, onPressed: send,
// onPressed: room.canSendDefaultMessages ? send : null,
icon: Icon(Icons.send), icon: Icon(Icons.send),
), ),
], ],

View file

@ -2,21 +2,19 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
import "package:matrix/matrix.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/thumbnail_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/image_data.dart";
import "package:nexus/widgets/chat_page/html/mention_chip.dart"; import "package:nexus/widgets/chat_page/html/mention_chip.dart";
import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; import "package:nexus/widgets/chat_page/html/spoiler_text.dart";
import "package:nexus/widgets/chat_page/html/code_block.dart"; import "package:nexus/widgets/chat_page/html/code_block.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart"; import "package:nexus/widgets/chat_page/html/quoted.dart";
import "package:nexus/widgets/error_dialog.dart";
class Html extends ConsumerWidget { class Html extends ConsumerWidget {
final String html; final String html;
final Client client; const Html(this.html, {super.key});
const Html(this.html, {required this.client, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
@ -38,35 +36,29 @@ class Html extends ConsumerWidget {
) )
: null, : null,
"blockquote" => Quoted(Html(element.innerHtml, client: client)), "blockquote" => Quoted(Html(element.innerHtml)),
"a" => "a" =>
element.attributes["href"]?.parseIdentifierIntoParts() == null element.attributes["href"]?.mention == null
? null ? null
: InlineCustomWidget(child: MentionChip(element.text)), : InlineCustomWidget(child: MentionChip(element.text)),
"img" => "img" =>
element.attributes["src"] == null element.attributes["src"] == null
? null ? null
: Consumer( : InlineCustomWidget(
builder: (_, ref, _) => ref
.watch(
ThumbnailController.provider(
ImageData(
uri: element.attributes["src"]!,
height: height,
width: width,
),
),
)
.when(
data: (uri) {
if (uri == null) return SizedBox.shrink();
return InlineCustomWidget(
child: Image.network( child: Image.network(
uri, Uri.parse(element.attributes["src"]!)
headers: client.headers, .mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
),
) ??
"",
)
.toString(),
headers: ref.headers,
errorBuilder: (_, error, _) => Text( errorBuilder: (_, error, _) => Text(
"Image Failed to Load", "Image Failed to Load",
style: TextStyle( style: TextStyle(
@ -80,19 +72,7 @@ class Html extends ConsumerWidget {
? child ? child
: CircularProgressIndicator(), : CircularProgressIndicator(),
), ),
);
},
error: ErrorDialog.new,
loading: () => InlineCustomWidget(
child: SizedBox(
width: width?.toDouble(),
height: height.toDouble(),
child: CircularProgressIndicator(),
), ),
),
),
),
("del" || ("del" ||
"h1" || "h1" ||
"h2" || "h2" ||

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:matrix/matrix.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
class MentionChip extends StatelessWidget { class MentionChip extends StatelessWidget {
final String label; final String label;
@ -8,7 +8,7 @@ class MentionChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) => ActionChip( Widget build(BuildContext context) => ActionChip(
label: Text( label: Text(
label.parseIdentifierIntoParts()?.primaryIdentifier ?? label, label.mention ?? label,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
@ -19,7 +19,7 @@ class MentionChip extends StatelessWidget {
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
child: Text("TODO: Open room or join room dialog, or user popover"), child: Text("TODO: Open room or join room dialog, or user popover"),
), // TODO ),
), ),
); );
} }

View file

@ -1,10 +1,8 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends ConsumerWidget { class MemberList extends ConsumerWidget {
@ -22,7 +20,7 @@ class MemberList extends ConsumerWidget {
AppBar( AppBar(
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
leading: Icon(Icons.people), leading: Icon(Icons.people),
title: Text("Members"), title: Text("Members (${members.length})"),
actionsPadding: EdgeInsets.only(right: 4), actionsPadding: EdgeInsets.only(right: 4),
actions: [ actions: [
if (Scaffold.of(context).hasEndDrawer) if (Scaffold.of(context).hasEndDrawer)
@ -32,30 +30,25 @@ class MemberList extends ConsumerWidget {
), ),
], ],
), ),
...members ...members.map(
.where(
(membership) =>
membership.content["membership"] ==
Membership.join.name,
)
.map(
(member) => ListTile( (member) => ListTile(
onTap: () {}, onTap: () => showDialog(
leading: AvatarOrHash( context: context,
ref builder: (context) =>
.watch( Dialog(child: Text("TODO: Open member popover")),
AvatarController.provider(
member.content["avatar_url"].toString(),
), ),
) leading: AvatarOrHash(
.whenOrNull(data: (data) => data), Uri.tryParse(member.content["avatar_url"] ?? ""),
member.content["displayname"].toString(), member.content["displayname"].toString(),
headers: room.client.headers,
), ),
title: Text( title: Text(
member.content["displayname"].toString(), member.content["displayname"].toString(),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text(
member.authorId,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
], ],

View file

@ -1,11 +1,9 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/members_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -23,7 +21,10 @@ class MentionOverlay extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) => Padding( Widget build(BuildContext context, WidgetRef ref) {
final rooms = ref.watch(RoomsController.provider);
return Padding(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
@ -41,9 +42,9 @@ class MentionOverlay extends ConsumerWidget {
? members ? members
: members.where( : members.where(
(member) => (member) =>
member.senderId.toLowerCase().contains( member.authorId
query.toLowerCase(), .toLowerCase()
) || .contains(query.toLowerCase()) ||
(member.content["displayname"] (member.content["displayname"]
as String?) as String?)
?.toLowerCase() ?.toLowerCase()
@ -55,24 +56,18 @@ class MentionOverlay extends ConsumerWidget {
.map( .map(
(member) => ListTile( (member) => ListTile(
leading: AvatarOrHash( leading: AvatarOrHash(
ref Uri.tryParse(
.watch( member.content["avatar_url"] ?? "",
AvatarController.provider(
member.content["avatar_url"]
.toString(),
), ),
) member.content["displayname"] ?? "",
.whenOrNull(data: (data) => data),
member.content["displayname"].toString(),
headers: room.client.headers,
), ),
title: Text( title: Text(
member.content["displayname"] as String? ?? member.content["displayname"] as String? ??
member.senderId, member.authorId,
), ),
onTap: () => addTag( onTap: () => addTag(
id: member.senderId, id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.authorId})",
name: member.senderId name: member.authorId
.substring(1) .substring(1)
.split(":") .split(":")
.first, .first,
@ -82,49 +77,45 @@ class MentionOverlay extends ConsumerWidget {
.toList(), .toList(),
), ),
), ),
"#" => "#" => ListView(
ref
.watch(RoomsController.provider)
.betterWhen(
data: (rooms) => ListView(
children: children:
(query.isEmpty (query.isEmpty
? rooms ? rooms.values
: rooms.where( : rooms.values.where(
(room) => room.title.toLowerCase().contains( (room) => (room.metadata?.name ?? "Unnamed Room")
query.toLowerCase(), .toLowerCase()
), .contains(query.toLowerCase()),
)) ))
.map( .map(
(room) => ListTile( (room) => ListTile(
leading: AvatarOrHash( leading: AvatarOrHash(
room.avatar, room.metadata?.avatar,
room.title, room.metadata?.name ?? "Unnamed Room",
fallback: Icon(Icons.numbers), fallback: Icon(Icons.numbers),
headers: room.roomData.client.headers,
), ),
title: Text(room.title), title: Text(room.metadata?.name ?? "Unnamed Room"),
subtitle: room.roomData.topic.isEmpty subtitle: room.metadata?.topic == null
? null ? null
: Text(room.roomData.topic, maxLines: 1), : Text(room.metadata!.topic!, maxLines: 1),
onTap: () => addTag( onTap: () => addTag(
id: "[#${room.roomData.getLocalizedDisplayname()}](https://matrix.to/#/${room.roomData.id})", id: "[#${room.metadata?.name ?? "Unnamed Room"}](https://matrix.to/#/${room.metadata?.id})",
name: name:
(room.roomData.canonicalAlias.isEmpty (room.metadata?.canonicalAlias ??
? room.roomData.id room.metadata?.id)
: room.roomData.canonicalAlias) ?.substring(1)
.substring(1)
.split(":") .split(":")
.first, .first ??
"",
), ),
), ),
) )
.toList(), .toList(),
), ),
),
_ => Loading(), _ => Loading(),
}, },
), ),
), ),
); );
}
} }

View file

@ -1,22 +1,16 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RelationPreview extends ConsumerWidget { class RelationPreview extends ConsumerWidget {
final Message? relatedMessage; final Message? relatedMessage;
final RelationType relationType; final RelationType relationType;
final VoidCallback onDismiss; final VoidCallback onDismiss;
final Room room;
const RelationPreview({ const RelationPreview({
required this.relatedMessage, required this.relatedMessage,
required this.relationType, required this.relationType,
required this.onDismiss, required this.onDismiss,
required this.room,
super.key, super.key,
}); });
@ -37,18 +31,18 @@ class RelationPreview extends ConsumerWidget {
"Editing message:", "Editing message:",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
AvatarOrHash( // AvatarOrHash(
ref // ref
.watch( // .watch(
AvatarController.provider( // AvatarController.provider(
relatedMessage!.metadata!["avatarUrl"], // relatedMessage!.metadata!["avatarUrl"],
), // ),
) // )
.whenOrNull(data: (data) => data), // .whenOrNull(data: (data) => data),
relatedMessage!.metadata!["displayName"].toString(), // relatedMessage!.metadata!["displayName"].toString(),
headers: room.client.headers, // headers: room.client.headers,
height: 16, // height: 16,
), // ),
Text( Text(
relatedMessage!.metadata?["displayName"] ?? relatedMessage!.metadata?["displayName"] ??
relatedMessage!.authorId, relatedMessage!.authorId,

View file

@ -1,13 +1,13 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/full_room.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/room_menu.dart"; import "package:nexus/widgets/chat_page/room_menu.dart";
class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
final bool isDesktop; final bool isDesktop;
final FullRoom room; final Room room;
final void Function(BuildContext context) onOpenMemberList; final void Function(BuildContext context) onOpenMemberList;
final void Function(BuildContext context) onOpenDrawer; final void Function(BuildContext context) onOpenDrawer;
const RoomAppbar( const RoomAppbar(
@ -25,21 +25,24 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) => Appbar( Widget build(BuildContext context) => Appbar(
leading: isDesktop leading: isDesktop
? AvatarOrHash( ? AvatarOrHash(
room.avatar, room.metadata?.avatar,
room.title, room.metadata?.name ?? "Unnamed Rooms",
height: 24, height: 24,
fallback: Icon(Icons.numbers), fallback: Icon(Icons.numbers),
headers: room.roomData.client.headers,
) )
: DrawerButton(onPressed: () => onOpenDrawer(context)), : DrawerButton(onPressed: () => onOpenDrawer(context)),
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(room.title, overflow: TextOverflow.ellipsis, maxLines: 1),
if (room.roomData.topic.isNotEmpty)
Text( Text(
room.roomData.topic, room.metadata?.name ?? "Unnamed Room",
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (room.metadata?.topic?.isNotEmpty == true)
Text(
room.metadata!.topic!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
@ -54,7 +57,7 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
onPressed: () => onOpenMemberList(context), onPressed: () => onOpenMemberList(context),
icon: Icon(Icons.people), icon: Icon(Icons.people),
), ),
RoomMenu(room.roomData), RoomMenu(room),
], ].toIList(),
); );
} }

View file

@ -9,6 +9,8 @@ import "package:flyer_chat_image_message/flyer_chat_image_message.dart";
import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; import "package:flyer_chat_system_message/flyer_chat_system_message.dart";
import "package:flyer_chat_text_message/flyer_chat_text_message.dart"; import "package:flyer_chat_text_message/flyer_chat_text_message.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
@ -16,6 +18,7 @@ import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/report_request.dart";
import "package:nexus/widgets/chat_page/chat_box.dart"; import "package:nexus/widgets/chat_page/chat_box.dart";
import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/member_list.dart"; import "package:nexus/widgets/chat_page/member_list.dart";
@ -24,7 +27,6 @@ import "package:nexus/widgets/chat_page/top_widget.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
// import "package:dynamic_polls/dynamic_polls.dart"; // import "package:dynamic_polls/dynamic_polls.dart";
// import "package:matrix/matrix.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
final bool isDesktop; final bool isDesktop;
@ -37,17 +39,17 @@ class RoomChat extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(ClientController.provider.notifier);
final replyToMessage = useState<Message?>(null); final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(showMembersByDefault); final memberListOpened = useState<bool>(showMembersByDefault);
final relationType = useState(RelationType.reply); final relationType = useState(RelationType.reply);
final room = ref.watch(SelectedRoomController.provider);
final userId = ref.watch(ClientStateController.provider)?.userId;
final theme = Theme.of(context); final theme = Theme.of(context);
final danger = theme.colorScheme.error; final danger = theme.colorScheme.error;
return ref if (room == null || userId == null || room.metadata?.id == null) {
.watch(SelectedRoomController.provider)
.betterWhen(
data: (room) {
if (room == null) {
return Center( return Center(
child: Text( child: Text(
"Nothing to see here...", "Nothing to see here...",
@ -55,44 +57,35 @@ class RoomChat extends HookConsumerWidget {
), ),
); );
} }
final controllerProvider = RoomChatController.provider(
room.roomData, final controllerProvider = RoomChatController.provider(room.metadata!.id);
);
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
List<PopupMenuEntry> getMessageOptions(Message message) => [ List<PopupMenuEntry> getMessageOptions(Message message) {
final isSentByMe = message.authorId == userId;
return [
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
replyToMessage.value = message; replyToMessage.value = message;
relationType.value = RelationType.reply; relationType.value = RelationType.reply;
}, },
child: ListTile( child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
leading: Icon(Icons.reply),
title: Text("Reply"),
), ),
), if (message is TextMessage && isSentByMe)
// Should check if is state event (has state_key), if so, don't show edit option
if (message is TextMessage &&
message.authorId == room.roomData.client.userID)
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
replyToMessage.value = message; replyToMessage.value = message;
relationType.value = RelationType.edit; relationType.value = RelationType.edit;
}, },
child: ListTile( child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
leading: Icon(Icons.edit),
title: Text("Edit"),
), ),
), if (isSentByMe) // TODO: Or if user has permission to redact others' messages
if (message.authorId == room.roomData.client.userID ||
room.roomData.canRedact)
PopupMenuItem( PopupMenuItem(
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (context) => HookBuilder( builder: (context) => HookBuilder(
builder: (_) { builder: (_) {
final deleteReasonController = final deleteReasonController = useTextEditingController();
useTextEditingController();
return AlertDialog( return AlertDialog(
title: Text("Delete Message"), title: Text("Delete Message"),
content: Column( content: Column(
@ -131,18 +124,32 @@ class RoomChat extends HookConsumerWidget {
}, },
), ),
), ),
child: ListTile( child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")),
leading: Icon(Icons.delete),
title: Text("Delete"),
),
), ),
PopupMenuItem( PopupMenuItem(
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => HookBuilder(
builder: (_) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text("Report"), title: Text("Report"),
content: Text( content: Column(
"Report this message to your server administrators, who can take action like banning that user or blocking that server from federating.", mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Report this event to your server administrators, who can take action like banning this server or room.",
),
SizedBox(height: 12),
FormTextInput(
required: false,
capitalize: true,
controller: reasonController,
title: "Reason for report (optional)",
),
],
), ),
actions: [ actions: [
TextButton( TextButton(
@ -151,15 +158,23 @@ class RoomChat extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
room.roomData.client.reportEvent( if (room.metadata == null) return;
room.roomData.id, client.reportEvent(
message.id, ReportRequest(
roomId: room.metadata!.id,
eventId: message.id,
reason: reasonController.text.isEmpty
? null
: reasonController.text,
),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text("Report"), child: Text("Report"),
), ),
], ],
);
},
), ),
), ),
child: ListTile( child: ListTile(
@ -168,6 +183,7 @@ class RoomChat extends HookConsumerWidget {
), ),
), ),
]; ];
}
return Scaffold( return Scaffold(
appBar: RoomAppbar( appBar: RoomAppbar(
@ -189,17 +205,11 @@ class RoomChat extends HookConsumerWidget {
.watch(controllerProvider) .watch(controllerProvider)
.betterWhen( .betterWhen(
data: (controller) => Chat( data: (controller) => Chat(
currentUserId: room.roomData.client.userID!, currentUserId: userId,
theme: ChatTheme.fromThemeData(theme) theme: ChatTheme.fromThemeData(theme).copyWith(
.copyWith( colors: ChatColors.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme) primary: theme.colorScheme.primaryContainer,
.copyWith( onPrimary: theme.colorScheme.onPrimaryContainer,
primary: theme
.colorScheme
.primaryContainer,
onPrimary: theme
.colorScheme
.onPrimaryContainer,
), ),
), ),
onMessageSecondaryTap: onMessageSecondaryTap:
@ -211,8 +221,7 @@ class RoomChat extends HookConsumerWidget {
}) => details?.globalPosition == null }) => details?.globalPosition == null
? null ? null
: context.showContextMenu( : context.showContextMenu(
globalPosition: globalPosition: details!.globalPosition,
details!.globalPosition,
children: getMessageOptions(message), children: getMessageOptions(message),
), ),
onMessageLongPress: onMessageLongPress:
@ -236,21 +245,16 @@ class RoomChat extends HookConsumerWidget {
showDialog( showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
backgroundColor: backgroundColor: Colors.transparent,
Colors.transparent,
insetPadding: EdgeInsets.all(64), insetPadding: EdgeInsets.all(64),
child: InteractiveViewer( child: InteractiveViewer(
child: Image( child: Image(
image: CachedNetworkImage( image: CachedNetworkImage(
message.source, message.source,
ref.watch( ref.watch(
CrossCacheController CrossCacheController.provider,
.provider,
), ),
headers: room headers: ref.headers,
.roomData
.client
.headers,
), ),
), ),
), ),
@ -263,18 +267,20 @@ class RoomChat extends HookConsumerWidget {
chatAnimatedListBuilder: (_, itemBuilder) => chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList( ChatAnimatedList(
itemBuilder: itemBuilder, itemBuilder: itemBuilder,
onEndReached: notifier.loadOlder, onEndReached: room.hasMore
onStartReached: notifier.markRead, ? notifier.loadOlder
: null,
onStartReached: () => client.markRead(room),
bottomPadding: 72, bottomPadding: 72,
), ),
composerBuilder: (_) => ChatBox( composerBuilder: (_) => ChatBox(
relationType: relationType.value, relationType: relationType.value,
relatedMessage: replyToMessage.value, relatedMessage: replyToMessage.value,
onDismiss: () => onDismiss: () => replyToMessage.value = null,
replyToMessage.value = null, room: room,
room: room.roomData,
), ),
// TODO: Polls
// customMessageBuilder: // customMessageBuilder:
// ( // (
// context, // context,
@ -317,7 +323,6 @@ class RoomChat extends HookConsumerWidget {
// groupStatus: groupStatus, // groupStatus: groupStatus,
// ), // ),
// // TODO: Make this actually work
// DynamicPolls( // DynamicPolls(
// startDate: DateTime.now(), // startDate: DateTime.now(),
// endDate: DateTime.now(), // endDate: DateTime.now(),
@ -395,8 +400,7 @@ class RoomChat extends HookConsumerWidget {
), ),
(m) { (m) {
// If it's already an <a> tag, leave it unchanged // If it's already an <a> tag, leave it unchanged
if (m.group(1) != if (m.group(1) != null) {
null) {
return m.group(1)!; return m.group(1)!;
} }
@ -406,48 +410,38 @@ class RoomChat extends HookConsumerWidget {
}, },
) )
.replaceAll("\n", "<br/>"), .replaceAll("\n", "<br/>"),
client: room.roomData.client,
), ),
if (message.editedAt != null) if (message.editedAt != null)
Text( Text(
"(edited)", "(edited)",
style: theme style: theme.textTheme.labelSmall,
.textTheme
.labelSmall,
), ),
], ],
), ),
topWidget: TopWidget( topWidget: TopWidget(
message, message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus, groupStatus: groupStatus,
), ),
message: message, message: message,
showTime: true, showTime: true,
index: index, index: index,
), ),
linkPreviewBuilder: linkPreviewBuilder: (_, message, isSentByMe) =>
(_, message, isSentByMe) => LinkPreview( LinkPreview(
text: message.text, text: message.text,
backgroundColor: isSentByMe backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary ? theme.colorScheme.inversePrimary
: theme : theme.colorScheme.surfaceContainerLow,
.colorScheme
.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric( insidePadding: EdgeInsets.symmetric(
vertical: 8, vertical: 8,
horizontal: 16, horizontal: 16,
), ),
linkPreviewData: linkPreviewData: message.linkPreviewData,
message.linkPreviewData, onLinkPreviewDataFetched: (linkPreviewData) =>
onLinkPreviewDataFetched:
(linkPreviewData) =>
notifier.updateMessage( notifier.updateMessage(
message, message,
message.copyWith( message.copyWith(
linkPreviewData: linkPreviewData: linkPreviewData,
linkPreviewData,
), ),
), ),
), ),
@ -461,24 +455,15 @@ class RoomChat extends HookConsumerWidget {
}) => FlyerChatImageMessage( }) => FlyerChatImageMessage(
topWidget: TopWidget( topWidget: TopWidget(
message, message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus, groupStatus: groupStatus,
alwaysShow: true, alwaysShow: true,
), ),
customImageProvider: customImageProvider: CachedNetworkImage(
CachedNetworkImage(
message.source, message.source,
ref.watch( ref.watch(CrossCacheController.provider),
CrossCacheController.provider, headers: ref.headers,
), ),
headers: room errorBuilder: (context, error, stackTrace) =>
.roomData
.client
.headers,
),
errorBuilder:
(context, error, stackTrace) =>
Center( Center(
child: Text( child: Text(
"Image Failed to Load", "Image Failed to Load",
@ -503,16 +488,12 @@ class RoomChat extends HookConsumerWidget {
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
child: Text( child: Text("TODO: Download Attachments"),
"TODO: Download Attachments", // TODO
),
), ),
), ),
child: FlyerChatFileMessage( child: FlyerChatFileMessage(
topWidget: TopWidget( topWidget: TopWidget(
message, message,
headers:
room.roomData.client.headers,
groupStatus: groupStatus, groupStatus: groupStatus,
), ),
message: message, message: message,
@ -539,8 +520,9 @@ class RoomChat extends HookConsumerWidget {
MessageGroupStatus? groupStatus, MessageGroupStatus? groupStatus,
}) => Text( }) => Text(
"${message.authorId} sent ${message.metadata?["eventType"]}", "${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall style: theme.textTheme.labelSmall?.copyWith(
?.copyWith(color: Colors.grey), color: Colors.grey,
),
), ),
), ),
resolveUser: notifier.resolveUser, resolveUser: notifier.resolveUser,
@ -553,15 +535,11 @@ class RoomChat extends HookConsumerWidget {
), ),
if (memberListOpened.value == true && showMembersByDefault) if (memberListOpened.value == true && showMembersByDefault)
MemberList(room.roomData), MemberList(room),
], ],
), ),
endDrawer: showMembersByDefault endDrawer: showMembersByDefault ? null : MemberList(room),
? null
: MemberList(room.roomData),
);
},
); );
} }
} }

View file

@ -1,38 +1,33 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:matrix/matrix.dart"; import "package:nexus/models/room.dart";
import "package:nexus/helpers/extensions/room_to_children.dart";
import "package:nexus/widgets/form_text_input.dart";
class RoomMenu extends StatelessWidget { class RoomMenu extends ConsumerWidget {
final Room room; final Room room;
const RoomMenu(this.room, {super.key}); final IList<Room> children;
const RoomMenu(this.room, {this.children = const IList.empty(), super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final danger = Theme.of(context).colorScheme.error; final danger = Theme.of(context).colorScheme.error;
final client = ref.watch(ClientController.provider.notifier);
void markRead(String roomId) async {
for (final child in await room.getAllChildren()) {
await child.roomData.setReadMarker(
child.roomData.lastEvent?.eventId,
mRead: child.roomData.lastEvent?.eventId,
);
}
}
return PopupMenuButton( return PopupMenuButton(
itemBuilder: (_) => [ itemBuilder: (_) => [
// PopupMenuItem(
// onTap: () async {
// final link = await room.matrixToInviteLink();
// await Clipboard.setData(ClipboardData(text: link.toString()));
// },
// child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
// ),
PopupMenuItem( PopupMenuItem(
onTap: () async { onTap: () async {
final link = await room.matrixToInviteLink(); await client.markRead(room);
await Clipboard.setData(ClipboardData(text: link.toString())); await Future.wait(children.map((child) => client.markRead(child)));
}, },
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
),
PopupMenuItem(
onTap: () => markRead(room.id),
child: ListTile( child: ListTile(
leading: Icon(Icons.check), leading: Icon(Icons.check),
title: Text("Mark as Read"), title: Text("Mark as Read"),
@ -44,7 +39,7 @@ class RoomMenu extends StatelessWidget {
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text("Leave Room"), title: Text("Leave Room"),
content: Text( content: Text(
"Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?", "Are you sure you want to leave \"${room.metadata?.name ?? "Unnamed Room"}\"?",
), ),
actions: [ actions: [
TextButton( TextButton(
@ -54,10 +49,13 @@ class RoomMenu extends StatelessWidget {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
final snackbar = ScaffoldMessenger.of( final snackbar = ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text("Leaving room..."))); content: Text("Leaving room..."),
await room.leave(); duration: Duration(days: 1),
),
);
await client.leaveRoom(room);
snackbar.close(); snackbar.close();
}, },
child: Text("Leave"), child: Text("Leave"),
@ -70,53 +68,53 @@ class RoomMenu extends StatelessWidget {
title: Text("Leave", style: TextStyle(color: danger)), title: Text("Leave", style: TextStyle(color: danger)),
), ),
), ),
PopupMenuItem( // PopupMenuItem(
onTap: () => showDialog( // onTap: () => showDialog(
context: context, // context: context,
builder: (context) => HookBuilder( // builder: (context) => HookBuilder(
builder: (_) { // builder: (_) {
final reasonController = useTextEditingController(); // final reasonController = useTextEditingController();
return AlertDialog( // return AlertDialog(
title: Text("Report"), // title: Text("Report"),
content: Column( // content: Column(
mainAxisSize: MainAxisSize.min, // mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, // crossAxisAlignment: CrossAxisAlignment.start,
children: [ // children: [
Text( // Text(
"Report this room to your server administrators, who can take action like banning this room.", // "Report this room to your server administrators, who can take action like banning this room.",
), // ),
SizedBox(height: 12), // SizedBox(height: 12),
FormTextInput( // FormTextInput(
required: false, // required: false,
capitalize: true, // capitalize: true,
controller: reasonController, // controller: reasonController,
title: "Reason for report (optional)", // title: "Reason for report (optional)",
), // ),
], // ],
), // ),
actions: [ // actions: [
TextButton( // TextButton(
onPressed: Navigator.of(context).pop, // onPressed: Navigator.of(context).pop,
child: Text("Cancel"), // child: Text("Cancel"),
), // ),
TextButton( // TextButton(
onPressed: () { // onPressed: () {
room.client.reportRoom(room.id, reasonController.text); // room.client.reportRoom(room.id, reasonController.text);
Navigator.of(context).pop(); // Navigator.of(context).pop();
}, // },
child: Text("Report"), // child: Text("Report"),
), // ),
], // ],
); // );
}, // },
), // ),
), // ),
child: ListTile( // child: ListTile(
leading: Icon(Icons.report, color: danger), // leading: Icon(Icons.report, color: danger),
title: Text("Report", style: TextStyle(color: danger)), // title: Text("Report", style: TextStyle(color: danger)),
), // ),
), // ),
], ],
); );
} }

View file

@ -1,4 +1,3 @@
import "package:collection/collection.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
@ -6,8 +5,6 @@ import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/selected_space_controller.dart"; import "package:nexus/controllers/selected_space_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/join_room_with_snackbars.dart"; import "package:nexus/helpers/extensions/join_room_with_snackbars.dart";
import "package:nexus/pages/settings_page.dart"; import "package:nexus/pages/settings_page.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
@ -22,58 +19,60 @@ class Sidebar extends HookConsumerWidget {
final selectedSpaceProvider = KeyController.provider( final selectedSpaceProvider = KeyController.provider(
KeyController.spaceKey, KeyController.spaceKey,
); );
final selectedSpace = ref.watch(selectedSpaceProvider); final selectedSpaceId = ref.watch(selectedSpaceProvider);
final selectedSpaceNotifier = ref.watch(selectedSpaceProvider.notifier); final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier);
final selectedRoomController = KeyController.provider( final selectedRoomController = KeyController.provider(
KeyController.roomKey, KeyController.roomKey,
); );
final selectedRoom = ref.watch(selectedRoomController); final selectedRoomId = ref.watch(selectedRoomController);
final selectedRoomNotifier = ref.watch(selectedRoomController.notifier); final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier);
final spaces = ref.watch(SpacesController.provider);
final indexOfSelected = spaces.indexWhere(
(space) => space.id == selectedSpaceId,
);
final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected;
final selectedSpace = ref.watch(SelectedSpaceController.provider);
final indexOfSelectedRoom = selectedSpace.children.indexWhere(
(room) => room.metadata?.id == selectedRoomId,
);
final selectedRoomIndex = indexOfSelectedRoom == -1
? selectedSpace.children.isEmpty
? null
: 0
: indexOfSelectedRoom;
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: Row( child: Row(
children: [ children: [
ref NavigationRail(
.watch(SpacesController.provider)
.when(
loading: SizedBox.shrink,
error: (error, stack) {
debugPrintStack(label: error.toString(), stackTrace: stack);
throw error;
},
data: (spaces) {
final indexOfSelected = spaces.indexWhere(
(space) => space.id == selectedSpace,
);
final selectedIndex = indexOfSelected == -1
? 0
: indexOfSelected;
return NavigationRail(
scrollable: true, scrollable: true,
onDestinationSelected: (value) { onDestinationSelected: (value) {
selectedSpaceNotifier.set(spaces[value].id); selectedSpaceIdNotifier.set(spaces[value].id);
selectedRoomNotifier.set( selectedRoomIdNotifier.set(
spaces[value].children.firstOrNull?.roomData.id, spaces[value].children.firstOrNull?.metadata?.id,
); );
}, },
destinations: spaces destinations: spaces
.map( .map(
(space) => NavigationRailDestination( (space) => NavigationRailDestination(
icon: AvatarOrHash( icon: AvatarOrHash(
space.avatar, space.room?.metadata?.avatar,
fallback: space.icon == null fallback: space.icon == null ? null : Icon(space.icon),
? null
: Icon(space.icon),
space.title, space.title,
headers: space.client.headers, hasBadge: space.children.any(
hasBadge: (room) => room.metadata?.unreadMessages != 0,
space.children.firstWhereOrNull( ),
(room) => room.roomData.hasNewMessages, badgeNumber: space.children.fold(
) != 0,
null, (previousValue, room) =>
previousValue +
(room.metadata?.unreadNotifications ?? 0),
),
), ),
label: Text(space.title), label: Text(space.title),
padding: EdgeInsets.only(top: 4), padding: EdgeInsets.only(top: 4),
@ -94,14 +93,12 @@ class Sidebar extends HookConsumerWidget {
context: context, context: context,
builder: (alertContext) => HookBuilder( builder: (alertContext) => HookBuilder(
builder: (_) { builder: (_) {
final roomAlias = final roomAlias = useTextEditingController();
useTextEditingController();
return AlertDialog( return AlertDialog(
title: Text("Join a Room"), title: Text("Join a Room"),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"Enter the room alias, ID, or a Matrix.to link.", "Enter the room alias, ID, or a Matrix.to link.",
@ -117,19 +114,15 @@ class Sidebar extends HookConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: Navigator.of( onPressed: Navigator.of(context).pop,
context,
).pop,
child: Text("Cancel"), child: Text("Cancel"),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(alertContext).pop(); Navigator.of(alertContext).pop();
final client = await ref.watch( final client = ref.watch(
ClientController ClientController.provider.notifier,
.provider
.future,
); );
if (context.mounted) { if (context.mounted) {
client.joinRoomWithSnackBars( client.joinRoomWithSnackBars(
@ -147,9 +140,7 @@ class Sidebar extends HookConsumerWidget {
), ),
), ),
child: ListTile( child: ListTile(
title: Text( title: Text("Join an existing room (or space)"),
"Join an existing room (or space)",
),
leading: Icon(Icons.numbers), leading: Icon(Icons.numbers),
), ),
), ),
@ -166,83 +157,71 @@ class Sidebar extends HookConsumerWidget {
IconButton( IconButton(
onPressed: () => showDialog( onPressed: () => showDialog(
context: context, context: context,
builder: (context) => builder: (context) => AlertDialog(title: Text("To-do")),
AlertDialog(title: Text("To-do")),
), ),
icon: Icon(Icons.explore), icon: Icon(Icons.explore),
), ),
IconButton( IconButton(
onPressed: () => Navigator.of(context).push( onPressed: () => Navigator.of(
MaterialPageRoute(builder: (_) => SettingsPage()), context,
), ).push(MaterialPageRoute(builder: (_) => SettingsPage())),
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
), ),
], ],
), ),
), ),
);
},
), ),
Expanded( Expanded(
child: ref child: Scaffold(
.watch(SelectedSpaceController.provider)
.betterWhen(
data: (space) {
final indexOfSelected = space.children.indexWhere(
(room) => room.roomData.id == selectedRoom,
);
final selectedIndex = indexOfSelected == -1
? space.children.isEmpty
? null
: 0
: indexOfSelected;
return Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
appBar: AppBar( appBar: AppBar(
leading: AvatarOrHash( leading: AvatarOrHash(
space.avatar, selectedSpace.room?.metadata?.avatar,
fallback: space.icon == null fallback: selectedSpace.icon == null
? null ? null
: Icon(space.icon), : Icon(selectedSpace.icon),
space.title,
headers: space.client.headers, selectedSpace.title,
), ),
title: Text( title: Text(
space.title, selectedSpace.title,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
actions: [ actions: [
if (space.roomData != null) RoomMenu(space.roomData!), if (selectedSpace.room != null)
RoomMenu(
selectedSpace.room!,
children: selectedSpace.children,
),
], ],
), ),
body: NavigationRail( body: NavigationRail(
scrollable: true, scrollable: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
extended: true, extended: true,
selectedIndex: selectedIndex, selectedIndex: selectedRoomIndex,
destinations: space.children destinations: selectedSpace.children
.map( .map(
(room) => NavigationRailDestination( (room) => NavigationRailDestination(
label: Text(room.title), label: Text(room.metadata?.name ?? "Unnamed Room"),
icon: AvatarOrHash( icon: AvatarOrHash(
hasBadge: room.roomData.hasNewMessages, room.metadata?.avatar,
room.avatar, hasBadge: room.metadata?.unreadMessages != 0,
room.title, badgeNumber: room.metadata?.unreadNotifications ?? 0,
fallback: selectedSpace == "dms" room.metadata?.name ?? "Unnamed Room",
fallback: selectedSpaceId == "dms"
? null ? null
: Icon(Icons.numbers), : Icon(Icons.numbers),
headers: space.client.headers, // space.client.headers,
), ),
), ),
) )
.toList(), .toList(),
onDestinationSelected: (value) => selectedRoomNotifier onDestinationSelected: (value) => selectedRoomIdNotifier.set(
.set(space.children[value].roomData.id), selectedSpace.children[value].metadata?.id,
),
), ),
);
},
), ),
), ),
], ],

View file

@ -1,18 +1,16 @@
import "dart:math"; import "dart:math";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart"; import "package:nexus/widgets/chat_page/html/quoted.dart";
class TopWidget extends ConsumerWidget { class TopWidget extends ConsumerWidget {
final Message message; final Message message;
final bool alwaysShow; final bool alwaysShow;
final Map<String, String> headers;
final MessageGroupStatus? groupStatus; final MessageGroupStatus? groupStatus;
const TopWidget( const TopWidget(
this.message, { this.message, {
required this.headers,
required this.groupStatus, required this.groupStatus,
this.alwaysShow = false, this.alwaysShow = false,
super.key, super.key,
@ -62,10 +60,10 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
Avatar( AvatarOrHash(
userId: replyMessage.authorId, Uri.tryParse(replyMessage.metadata?["avatarUrl"] ?? ""),
headers: headers, replyMessage.metadata?["displayName"] ?? "",
size: 16, height: 16,
), ),
Flexible( Flexible(
child: Text( child: Text(
@ -104,7 +102,10 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
Avatar(userId: message.authorId, headers: headers), AvatarOrHash(
Uri.parse(message.metadata?["avatarUrl"] ?? ""),
message.metadata?["displayName"] ?? "",
),
Flexible( Flexible(
child: Text( child: Text(
message.metadata?["displayName"] ?? message.authorId, message.metadata?["displayName"] ?? message.authorId,

View file

@ -20,11 +20,13 @@ class FormTextInput extends StatelessWidget {
final Widget? trailing; final Widget? trailing;
final InputBorder? border; final InputBorder? border;
final List<TextInputFormatter>? formatters; final List<TextInputFormatter>? formatters;
final bool autofocus;
const FormTextInput({ const FormTextInput({
super.key, super.key,
this.border, this.border,
this.controller, this.controller,
this.autofocus = false,
this.title, this.title,
this.obscure = false, this.obscure = false,
this.readOnly = false, this.readOnly = false,
@ -45,6 +47,7 @@ class FormTextInput extends StatelessWidget {
@override @override
Widget build(BuildContext context) => TextFormField( Widget build(BuildContext context) => TextFormField(
autofocus: autofocus,
controller: controller, controller: controller,
keyboardType: keyboardType, keyboardType: keyboardType,
readOnly: readOnly, readOnly: readOnly,

View file

@ -13,8 +13,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_vodozemac
rust_lib_nexus
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View file

@ -73,14 +73,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.0"
base58check:
dependency: transitive
description:
name: base58check
sha256: "6c300dfc33e598d2fe26319e13f6243fea81eaf8204cb4c6b69ef20a625319a5"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
blurhash_dart: blurhash_dart:
dependency: transitive dependency: transitive
description: description:
@ -105,14 +97,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.3" version: "4.0.3"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
build_config: build_config:
dependency: transitive dependency: transitive
description: description:
@ -153,14 +137,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.1" version: "8.12.1"
canonical_json:
dependency: transitive
description:
name: canonical_json
sha256: d6be1dd66b420c6ac9f42e3693e09edf4ff6edfee26cb4c28c1c019fdb8c0c15
url: "https://pub.dev"
source: hosted
version: "1.1.2"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -217,6 +193,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: "direct main"
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@ -394,13 +378,21 @@ packages:
source: hosted source: hosted
version: "11.1.0" version: "11.1.0"
ffi: ffi:
dependency: transitive dependency: "direct main"
description: description:
name: ffi name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.1.5"
ffigen:
dependency: "direct main"
description:
name: ffigen
sha256: b7803707faeec4ce3c1b0c2274906504b796e3b70ad573577e72333bd1c9b3ba
url: "https://pub.dev"
source: hosted
version: "20.1.1"
file: file:
dependency: transitive dependency: transitive
description: description:
@ -541,14 +533,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
flutter_rust_bridge:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
flutter_secure_storage: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@ -610,14 +594,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_vodozemac:
dependency: "direct main"
description:
name: flutter_vodozemac
sha256: "16d4b44dd338689441fe42a80d0184e5c864e9563823de9e7e6371620d2c0590"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -728,6 +704,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
hooks:
dependency: "direct main"
description:
name: hooks
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
hooks_riverpod: hooks_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@ -744,14 +728,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.15.6" version: "0.15.6"
html_unescape:
dependency: transitive
description:
name: html_unescape
sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -936,14 +912,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1"
url: "https://pub.dev"
source: hosted
version: "7.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -960,14 +928,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.11.1"
matrix:
dependency: "direct main"
description:
name: matrix
sha256: fb116ee89f6871441f22f76a988db15cfcfb6dfac97e3e2d654c240080015707
url: "https://pub.dev"
source: hosted
version: "4.1.0"
mention_tag_text_field: mention_tag_text_field:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1160,14 +1120,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
random_string: quiver:
dependency: transitive dependency: transitive
description: description:
name: random_string name: quiver
sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02" sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "3.2.2"
riverpod: riverpod:
dependency: transitive dependency: transitive
description: description:
@ -1192,13 +1152,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
rust_lib_nexus:
dependency: "direct main"
description:
path: rust_builder
relative: true
source: path
version: "0.0.1"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -1255,14 +1208,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.26.3" version: "1.26.3"
sdp_transform:
dependency: transitive
description:
name: sdp_transform
sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
sembast: sembast:
dependency: transitive dependency: transitive
description: description:
@ -1364,14 +1309,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
slugify:
dependency: transitive
description:
name: slugify
sha256: b272501565cb28050cac2d96b7bf28a2d24c8dae359280361d124f3093d337c3
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_gen: source_gen:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -1412,30 +1349,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "8d7b8749a516cbf6e9057f9b480b716ad14fc4f3d3873ca6938919cc626d9025"
url: "https://pub.dev"
source: hosted
version: "2.3.7+1"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -1556,14 +1469,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "2.3.1"
unorm_dart:
dependency: transitive
description:
name: unorm_dart
sha256: "5b35bff83fce4d76467641438f9e867dc9bcfdb8c1694854f230579d68cd8f4b"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1676,15 +1581,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
vodozemac:
dependency: "direct main"
description:
path: dart
ref: "krille/use-specced-olm-session-config"
resolved-ref: "8770e0555b1bb692e3e1a43a7726b27eae285b20"
url: "https://github.com/famedly/dart-vodozemac"
source: git
version: "0.4.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
@ -1725,14 +1621,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View file

@ -12,11 +12,6 @@ environment:
sdk: "^3.9.2" sdk: "^3.9.2"
dependency_overrides: dependency_overrides:
vodozemac:
git:
url: https://github.com/famedly/dart-vodozemac
ref: krille/use-specced-olm-session-config
path: dart
analyzer: ^8.4.0 analyzer: ^8.4.0
source_gen: ^4.0.2 source_gen: ^4.0.2
flutter_hooks: ^0.21.2 flutter_hooks: ^0.21.2
@ -59,14 +54,10 @@ dependencies:
git: git:
url: https://github.com/Henry-Hiles/flutter_chat_ui url: https://github.com/Henry-Hiles/flutter_chat_ui
path: packages/flutter_link_previewer path: packages/flutter_link_previewer
matrix: ^4.1.0
sqflite_common_ffi: ^2.3.6
color_hash: ^1.0.1 color_hash: ^1.0.1
flutter_vodozemac: ^0.4.1
flutter_widget_from_html_core: ^0.17.0 flutter_widget_from_html_core: ^0.17.0
flutter_svg: ^2.2.2 flutter_svg: ^2.2.2
json_annotation: ^4.9.0 json_annotation: ^4.9.0
vodozemac: ^0.4.0
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
mention_tag_text_field: ^0.0.9 mention_tag_text_field: ^0.0.9
fluttertagger: ^2.3.1 fluttertagger: ^2.3.1
@ -74,9 +65,10 @@ dependencies:
dynamic_polls: ^0.0.6 dynamic_polls: ^0.0.6
flutter_hooks: ^0.21.3+1 flutter_hooks: ^0.21.3+1
cross_cache: ^1.1.0 cross_cache: ^1.1.0
rust_lib_nexus: ffi: ^2.1.5
path: rust_builder hooks: ^1.0.0
# flutter_rust_bridge: 2.11.1 code_assets: ^1.0.0
ffigen: ^20.1.1
dev_dependencies: dev_dependencies:
build_runner: ^2.4.11 build_runner: ^2.4.11

1
rust/.gitignore vendored
View file

@ -1 +0,0 @@
/target

4454
rust/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
[package]
name = "rust_lib_nexus"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
flutter_rust_bridge = "=2.11.1"
matrix-sdk = "0.16.0"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }

View file

@ -1 +0,0 @@
pub mod simple;

View file

@ -1,10 +0,0 @@
#[flutter_rust_bridge::frb(sync)] // Synchronous mode for simplicity of the demo
pub fn greet(name: String) -> String {
format!("Hello, {name}!")
}
#[flutter_rust_bridge::frb(init)]
pub fn init_app() {
// Default utilities - feel free to customize
flutter_rust_bridge::setup_default_user_utils();
}

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
pub mod api;
mod frb_generated;

View file

@ -1,29 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
build/

View file

@ -1 +0,0 @@
Please ignore this folder, which is just glue to build Rust with Flutter.

View file

@ -1,9 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

View file

@ -1,56 +0,0 @@
// The Android Gradle Plugin builds the native code with the Android NDK.
group 'com.flutter_rust_bridge.rust_lib_nexus'
version '1.0'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
// The Android Gradle Plugin knows how to build native code with the NDK.
classpath 'com.android.tools.build:gradle:7.3.0'
}
}
rootProject.allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
android {
if (project.android.hasProperty("namespace")) {
namespace 'com.flutter_rust_bridge.rust_lib_nexus'
}
// Bumping the plugin compileSdkVersion requires all clients of this plugin
// to bump the version in their app.
compileSdkVersion 33
// Use the NDK version
// declared in /android/app/build.gradle file of the Flutter project.
// Replace it with a version number if this plugin requires a specfic NDK version.
// (e.g. ndkVersion "23.1.7779620")
ndkVersion android.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion 19
}
}
apply from: "../cargokit/gradle/plugin.gradle"
cargokit {
manifestDir = "../../rust"
libname = "rust_lib_nexus"
}

View file

@ -1 +0,0 @@
rootProject.name = 'rust_lib_nexus'

View file

@ -1,3 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.flutter_rust_bridge.rust_lib_nexus">
</manifest>

View file

@ -1,4 +0,0 @@
target
.dart_tool
*.iml
!pubspec.lock

View file

@ -1,42 +0,0 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Copyright 2022 Matej Knopp
================================================================================
MIT LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
APACHE LICENSE, VERSION 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

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