Compare commits

..

2 commits

Author SHA1 Message Date
4569aeac33
Rust Rewrite WIP 2026-01-17 14:29:38 -05:00
c9a87ddc34
add note about rust 2026-01-17 14:28:20 -05:00
144 changed files with 46100 additions and 2905 deletions

1
.gitignore vendored
View file

@ -36,7 +36,6 @@ 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
- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 - [ ] 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.
- [ ] Platform Support - [ ] Platform Support
- [x] Linux - [x] Linux
- [x] Windows - [x] Windows
@ -33,17 +33,14 @@ 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 a text/uri/link - [x] Using alias/id/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
- [x] Restoring crypto identity from passphrase/key or verification - [ ] Restoring crypto identity from passphrase/key or verification
- [x] Sending - [x] Sending
- [x] Plain text - [x] Plain text
- [x] HTML/Markdown - [x] HTML/Markdown
@ -150,4 +147,4 @@ flutter run
## Community ## Community
Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. Come chat in the [Federated Nexus Community](https://matrix.to/#/#space:federated.nexus) for questions or help with developing or using Nexus Client.

View file

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

View file

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

3
devtools_options.yaml Normal file
View file

@ -0,0 +1,3 @@
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,72 +23,51 @@
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 = pkgs.mkShell { devShells.default =
let
# android = pkgs.callPackage ./nix/android.nix { };
in
pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
go # jdk17
olm cargo
git rustc
clang openssl_3
usedFlutter pkg-config
(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 = { env = rec {
LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}:./build/native_assets/linux"; LD_LIBRARY_PATH = "${
CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; pkgs.lib.makeLibraryPath ([
}; pkgs.sqlite
}; ])
}:./build/linux/x64/debug/plugins/flutter_vodozemac:./build/linux/x64/debug/plugins/rust_lib_nexus";
packages.default = usedFlutter.buildFlutterApplication { # ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
inherit src buildInputs; # ANDROID_SDK_ROOT = ANDROID_HOME;
pname = package.name; # JAVA_HOME = pkgs.jdk17;
version = package.version;
pubspecLock = importYAML "${src}/pubspec.lock"; # TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}";
gitHashes = { # GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2";
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
'';
}; };
}; };
}; };

3
flutter_rust_bridge.yaml Normal file
View file

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

View file

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

@ -0,0 +1,17 @@
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,213 +1,106 @@
import "dart:developer"; import "dart:convert";
import "dart:ffi"; import "dart:io";
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:nexus/controllers/client_state_controller.dart"; import "package:matrix/encryption.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/database_controller.dart";
import "package:nexus/controllers/space_edges_controller.dart"; import "package:vodozemac/vodozemac.dart" as vod;
import "package:nexus/controllers/sync_status_controller.dart"; import "package:flutter_vodozemac/flutter_vodozemac.dart" as fl_vod;
import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:matrix/matrix.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<int> { class ClientController extends AsyncNotifier<Client> {
@override @override
Future<int> build() async { bool updateShouldNotify(
final handle = await Isolate.run(GomuksInit); AsyncValue<Client> previous,
AsyncValue<Client> next,
) =>
previous.hasValue != next.hasValue ||
previous.value?.accessToken != next.value?.accessToken;
static const sessionBackupKey = "sessionBackup";
final callable = @override
NativeCallable< Future<Client> build() async {
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer) if (!vod.isInitialized()) fl_vod.init();
>.listener(( final client = Client(
Pointer<Char> command, "nexus",
int requestId, logLevel: kReleaseMode ? Level.warning : Level.verbose,
GomuksOwnedBuffer data, importantStateEvents: {"im.ponies.room_emotes"},
) { supportedLoginTypes: {AuthenticationTypes.password},
try { verificationMethods: {KeyVerificationMethod.emoji},
final muksEventType = command.cast<Utf8>().toDartString(); database: await MatrixSdkDatabase.init(
debugPrint("Handling $muksEventType..."); "nexus",
final decodedMuksEvent = data.toJson(); database: await ref.watch(DatabaseController.provider.future),
),
switch (muksEventType) { nativeImplementations: NativeImplementationsIsolate(
case "client_state": compute,
ref vodozemacInit: fl_vod.init,
.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 backupJson = await ref
.watch(SecureStorageController.provider.notifier)
.get(sessionBackupKey);
final json = response.buf.toJson(); if (backupJson != null) {
if (json is String) throw json; final backup = SessionBackup.fromJson(json.decode(backupJson));
return json;
await client.init(
waitForFirstSync: false,
newToken: backup.accessToken,
newHomeserver: backup.homeserver,
newUserID: backup.userID,
newDeviceID: backup.deviceID,
newDeviceName: backup.deviceName,
);
} }
Future<void> redactEvent(RedactEventRequest report) => return client;
_sendCommand("redact_event", report.toJson()); }
Future<void> sendMessage(SendMessageRequest request) => Future<bool> setHomeserver(Uri homeserverUrl) async {
_sendCommand("send_message", request.toJson()); final client = await future;
Future<bool> verify(String recoveryKey) async {
try { try {
await _sendCommand("verify", {"recovery_key": recoveryKey}); await client.checkHomeserver(homeserverUrl);
return true; return true;
} catch (error) { } catch (_) {
return false; return false;
} }
} }
Future<String> joinRoom(JoinRoomRequest request) async { Future<bool> login(String username, String password) async {
final response = await _sendCommand("join_room", request.toJson()); final client = await future;
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 {
await _sendCommand("login", login.toJson()); final deviceName = "Nexus Client login on ${Platform.localHostname}";
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 (error) { } catch (_) {
return false; return false;
} }
} }
Future<String?> discoverHomeserver(Uri homeserver) async { static final provider = AsyncNotifierProvider<ClientController, Client>(
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

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

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

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

@ -1,27 +0,0 @@
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,27 +1,22 @@
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/models/event.dart"; import "package:matrix/matrix.dart";
import "package:nexus/models/room.dart";
class MembersController extends AsyncNotifier<IList<Event>> { class MembersController extends AsyncNotifier<IList<MatrixEvent>> {
final Room room; final Room room;
MembersController(this.room); MembersController(this.room);
@override @override
Future<IList<Event>> build() async => Future<IList<MatrixEvent>> build() async => IList(
(room.state["m.room.member"]?.values ?? []) (await room.client.getMembersByRoom(
.map( room.id,
(eventRowId) => room.events.firstWhereOrNull( notMembership: Membership.leave,
(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<Event>, Room>( .autoDispose<MembersController, IList<MatrixEvent>, Room>(
MembersController.new, MembersController.new,
); );
} }

View file

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

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

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

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

View file

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

View file

@ -1,72 +1,43 @@
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:fluttertagger/fluttertagger.dart"; import "package:matrix/matrix.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/message_controller.dart"; import "package:nexus/controllers/events_controller.dart";
import "package:nexus/controllers/messages_controller.dart"; import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/controllers/new_events_controller.dart"; import "package:nexus/helpers/extensions/list_to_messages.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:fluttertagger/fluttertagger.dart" as tagger;
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 String roomId; final Room room;
RoomChatController(this.roomId); RoomChatController(this.room);
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
final client = ref.watch(ClientController.provider.notifier); final timeline = await ref.watch(EventsController.provider(room).future);
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(
ref.listen(NewEventsController.provider(roomId), (_, next) async { room.client.onTimelineEvent.stream.listen((event) async {
final controller = await future; if (event.roomId != room.id) return;
for (final event in next) {
if (event.type == "m.room.redaction") { if (event.type == EventTypes.Redaction) {
final controller = await future; final controller = await future;
final message = controller.messages.firstWhereOrNull( final message = controller.messages.firstWhereOrNull(
(message) => message.id == event.content["redacts"], (message) => message.id == event.redacts,
); );
if (message == null || !ref.mounted) return; if (message == null) return;
await controller.removeMessage(message); await controller.removeMessage(message);
} else { } else {
final message = await ref.watch( final message = await event.toMessage(includeEdits: true, timeline);
MessageController.provider( if (event.relationshipType == RelationshipTypes.edit) {
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.relatesTo, (element) => element.id == event.relationshipEventId,
); );
if (oldMessage == null || message == null || !ref.mounted) return; if (oldMessage == null || message == null) return;
return await updateMessage( return await updateMessage(
oldMessage, oldMessage,
message.copyWith( message.copyWith(
@ -74,62 +45,23 @@ class RoomChatController extends AsyncNotifier<ChatController> {
replyToMessageId: oldMessage.replyToMessageId, replyToMessageId: oldMessage.replyToMessageId,
metadata: { metadata: {
...(oldMessage.metadata ?? {}), ...(oldMessage.metadata ?? {}),
...(message.metadata ?? {}) ...((message.metadata ?? {}).filterMap(
.toIMap() (key, value) => value == null ? null : MapEntry(key, value),
.where((key, value) => value != null) )),
.unlock,
}, },
), ),
); );
} }
if (message != null && if (message != null) {
!controller.messages.any( return await insertMessage(message);
(oldMessage) => oldMessage.id == message.id,
) &&
ref.mounted) {
await controller.insertMessage(message);
} }
} }
} }).cancel,
}, weak: true).close,
); );
ref.onDispose(controller.dispose); return InMemoryChatController(
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 {
@ -149,61 +81,35 @@ 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 ref await room.redactEvent(message.id, reason: reason);
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: message.id,
roomId: roomId,
reason: reason,
),
);
} }
Future<void> loadOlder([InMemoryChatController? chatController]) async { Future<void> loadOlder() async {
final controller = chatController ?? await future; final currentEvents = await future;
final client = ref.watch(ClientController.provider.notifier); await ref.watch(EventsController.provider(room).notifier).prev();
final timeline = await ref.watch(EventsController.provider(room).future);
final response = await client.paginate( final controller = await future;
PaginateRequest(
roomId: roomId,
maxTimelineId: controller.messages.firstOrNull?.metadata?["timelineId"],
),
);
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,
),
)
.toIList(),
),
}),
const ISet.empty(),
);
final messages = await ref.watch(
MessagesController.provider(response.events.reversed).future,
);
await controller.insertAllMessages( await controller.insertAllMessages(
messages await timeline.events
.where( .where(
(newMessage) => !controller.messages.any( (event) => !currentEvents.messages.any(
(message) => message.id == newMessage.id, (existingEvent) => existingEvent.id == event.eventId,
), ),
) )
.toList(), .toList()
.toMessages(room, timeline),
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 =>
@ -211,7 +117,7 @@ class RoomChatController extends AsyncNotifier<ChatController> {
Future<void> send( Future<void> send(
String message, { String message, {
required Iterable<Tag> tags, required Iterable<tagger.Tag> tags,
required RelationType relationType, required RelationType relationType,
Message? relation, Message? relation,
}) async { }) async {
@ -227,42 +133,30 @@ class RoomChatController extends AsyncNotifier<ChatController> {
); );
} }
final client = ref.watch(ClientController.provider.notifier); await room.sendTextEvent(
client.sendMessage( taggedMessage,
SendMessageRequest( editEventId: relationType == RelationType.edit ? relation?.id : null,
roomId: roomId, inReplyTo: (relationType == RelationType.reply && relation != null)
mentions: Mentions( ? await room.getEventById(relation.id)
userIds: [ : null,
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 ref final user = await room.client.getUserProfile(id);
.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, String>( .autoDispose<RoomChatController, ChatController, Room>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -1,83 +1,23 @@
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/new_events_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/read_receipt.dart"; import "package:nexus/helpers/extensions/get_full_room.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/full_room.dart";
class RoomsController extends Notifier<IMap<String, Room>> { class RoomsController extends AsyncNotifier<IList<FullRoom>> {
@override @override
IMap<String, Room> build() => const IMap.empty(); Future<IList<FullRoom>> build() async {
final client = await ref.watch(ClientController.provider.future);
void update(IMap<String, Room> rooms, ISet<String> leftRooms) { ref.onDispose(
final merged = rooms.entries.fold(state, (acc, entry) { client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel,
final roomId = entry.key;
final incoming = entry.value;
final existing = acc[roomId];
final events = existing?.events.updateById(
incoming.events,
(item) => item.eventId,
); );
ref return IList(await Future.wait(client.rooms.map((room) => room.fullRoom)));
.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 = NotifierProvider<RoomsController, IMap<String, Room>>( static final provider =
AsyncNotifierProvider<RoomsController, IList<FullRoom>>(
RoomsController.new, RoomsController.new,
); );
} }

View file

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

View file

@ -4,10 +4,12 @@ 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 Notifier<Space> { class SelectedSpaceController extends AsyncNotifier<Space> {
@override @override
Space build() { Future<Space> build() async {
final spaces = ref.watch(SpacesController.provider); final spaces = await ref.watch(
SpacesController.provider.selectAsync((data) => data),
);
final selectedSpaceId = ref.watch( final selectedSpaceId = ref.watch(
KeyController.provider(KeyController.spaceKey), KeyController.provider(KeyController.spaceKey),
); );
@ -16,7 +18,7 @@ class SelectedSpaceController extends Notifier<Space> {
spaces.first; spaces.first;
} }
static final provider = NotifierProvider<SelectedSpaceController, Space>( static final provider = AsyncNotifierProvider<SelectedSpaceController, Space>(
SelectedSpaceController.new, SelectedSpaceController.new,
); );
} }

View file

@ -1,15 +0,0 @@
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,96 +1,77 @@
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/rooms_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/helpers/extensions/get_full_room.dart";
import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/helpers/extensions/room_to_children.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 Notifier<IList<Space>> { class SpacesController extends AsyncNotifier<IList<Space>> {
@override @override
IList<Space> build() { Future<IList<Space>> build() async {
final rooms = ref.watch(RoomsController.provider); final client = await ref.watch(ClientController.provider.future);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
final childRoomsBySpaceId = IMap.fromEntries( ref.onDispose(
topLevelSpaceIds.map((spaceId) { client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel,
ISet<String> walk(String currentId) {
final children = spaceEdges[currentId] ?? IList<SpaceEdge>();
return children.fold<ISet<String>>(const ISet.empty(), (acc, edge) {
final childId = edge.childId;
final isSpace = spaceEdges.containsKey(childId);
return acc
.addAll(!isSpace ? ISet([childId]) : const ISet.empty())
.addAll(isSpace ? walk(childId) : const ISet.empty());
});
}
return MapEntry(
spaceId,
walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(),
);
}),
); );
final allNestedRoomIds = childRoomsBySpaceId.values final topLevel = IList(
.expand((l) => l) await Future.wait(
.map( client.rooms
(room) => .where((room) => !room.isDirectChat)
rooms.entries.firstWhere((entry) => entry.value == room).key,
)
.toISet();
final otherRooms = rooms.entries
.where( .where(
(e) => (room) => client.rooms
!allNestedRoomIds.contains(e.key) && .where((room) => room.isSpace)
!topLevelSpaceIds.contains(e.key) && .every(
!spaceEdges.containsKey(e.key), (match) => match.spaceChildren.every(
) (child) => child.roomId != room.id,
.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(); )
.map((room) => room.fullRoom),
),
);
final topLevelSpaces = topLevel.where((r) => r.roomData.isSpace).toIList();
final topLevelRooms = topLevel.where((r) => !r.roomData.isSpace).toIList();
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 = NotifierProvider<SpacesController, IList<Space>>( static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
SpacesController.new, SpacesController.new,
); );
} }

View file

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

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

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

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

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,27 @@
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,18 +1,15 @@
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/verify_page.dart"; import "package:nexus/pages/settings_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";
@ -38,13 +35,11 @@ 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,
@ -86,11 +81,11 @@ void main() async {
); );
} }
class App extends StatelessWidget { class App extends ConsumerWidget {
const App({super.key}); const App({super.key});
@override @override
Widget build(BuildContext context) => DynamicColorBuilder( Widget build(BuildContext context, WidgetRef ref) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => MaterialApp( builder: (lightDynamic, darkDynamic) => MaterialApp(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
@ -104,36 +99,39 @@ class App extends StatelessWidget {
brightness: Brightness.dark, brightness: Brightness.dark,
)) ))
.theme, .theme,
home: Scaffold( home: Builder(
body: Consumer( builder: (context) => ref
builder: (_, ref, _) => ref .watch(SharedPrefsController.provider)
.watch(
MultiProviderController.provider(
IListConst([
SharedPrefsController.provider,
ClientController.provider,
HeaderController.provider,
]),
),
)
.betterWhen( .betterWhen(
data: (_) => Consumer( data: (_) => ref
builder: (_, ref, _) { .watch(ClientController.provider)
final clientState = ref.watch( .betterWhen(
ClientStateController.provider, data: (client) =>
); client.accessToken == null ? LoginPage() : ChatPage(),
if (clientState == null || !clientState.isInitialized) { loading: () => Scaffold(
return Loading(); body: Center(
} child: Column(
mainAxisSize: MainAxisSize.min,
if (!clientState.isLoggedIn) { spacing: 16,
return LoginPage(); children: [
} else if (!clientState.isVerified) { Text(
return VerifyPage(); "Syncing...",
} else { style: Theme.of(context).textTheme.headlineMedium,
return ChatPage(); ),
} Loading(),
}, ],
),
),
appBar: Appbar(
actions: [
IconButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => SettingsPage()),
),
icon: Icon(Icons.settings),
),
],
),
), ),
), ),
), ),

View file

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

@ -1,11 +0,0 @@
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;
}

View file

@ -1,79 +0,0 @@
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;
}

13
lib/models/full_room.dart Normal file
View file

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

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

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

@ -1,16 +0,0 @@
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);
}

View file

@ -1,17 +0,0 @@
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);
}

View file

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

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

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

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

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

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

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

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

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

View file

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

@ -1,54 +0,0 @@
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);
}

View file

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

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

@ -0,0 +1,17 @@
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,16 +1,20 @@
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:nexus/models/room.dart"; import "package:matrix/matrix.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 id,
required String title, required String title,
required String id,
required IList<FullRoom> children,
required Client client,
Room? roomData,
Uri? avatar,
IconData? icon, IconData? icon,
Room? room,
required IList<Room> children,
}) = _Space; }) = _Space;
} }

View file

@ -1,14 +0,0 @@
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);
}

View file

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

@ -1,18 +0,0 @@
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,13 +1,12 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/chat_page/sidebar.dart";
import "package:nexus/widgets/chat_page/room_chat.dart"; import "package:nexus/widgets/chat_page/room_chat.dart";
import "package:nexus/widgets/chat_page/sidebar.dart";
class ChatPage extends ConsumerWidget { class ChatPage extends StatelessWidget {
const ChatPage({super.key}); const ChatPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( Widget build(BuildContext context) => 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,7 +5,6 @@ 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";
@ -16,25 +15,27 @@ 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 homeserver = useState<String?>(null); final allowLogin = useState(false);
final launch = ref.watch(LaunchHelper.provider).launchUrl; final launch = ref.watch(LaunchHelper.provider).launchUrl;
Future<void> setHomeserver(Uri? newHomeserver) async { Future<void> setHomeserver(Uri? homeserver) async {
isLoading.value = true; isLoading.value = true;
final succeeded = homeserver == null
homeserver.value = newHomeserver == null ? false
? null : await ref
: await client.discoverHomeserver( .watch(ClientController.provider.notifier)
newHomeserver.hasScheme .setHomeserver(
? newHomeserver homeserver.hasScheme
: Uri.https(newHomeserver.path), ? homeserver
: Uri.https(homeserver.path),
); );
if (homeserver.value == null && context.mounted) { if (succeeded) {
allowLogin.value = true;
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -156,7 +157,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 (homeserver.value != null) ...[ else if (allowLogin.value) ...[
DividerText("Then, sign in:"), DividerText("Then, sign in:"),
SizedBox(height: 4), SizedBox(height: 4),
TextField( TextField(
@ -173,13 +174,9 @@ class LoginPage extends HookConsumerWidget {
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
isLoading.value = true; isLoading.value = true;
final succeeded = await client.login( final succeeded = await ref
LoginRequest( .watch(ClientController.provider.notifier)
username: username.text, .login(username.text, password.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

@ -1,82 +0,0 @@
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,5 +1,4 @@
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";
@ -8,7 +7,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 IList<Widget> actions; final List<Widget> actions;
const Appbar({ const Appbar({
super.key, super.key,
@ -16,7 +15,7 @@ class Appbar extends StatelessWidget implements PreferredSizeWidget {
this.backgroundColor, this.backgroundColor,
this.scrolledUnderElevation, this.scrolledUnderElevation,
this.leading, this.leading,
this.actions = const IList.empty(), this.actions = const [],
}); });
@override @override

View file

@ -1,34 +1,28 @@
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 ConsumerWidget { class AvatarOrHash extends StatelessWidget {
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, WidgetRef ref) { Widget build(BuildContext context) {
final box = ColoredBox( final box = ColoredBox(
color: ColorHash(title).color, color: ColorHash(title).color,
child: Center(child: Text(title.isEmpty ? "" : title[0])), child: Center(child: Text(title[0])),
); );
return SizedBox( return SizedBox(
width: height, width: height,
@ -36,7 +30,6 @@ class AvatarOrHash extends ConsumerWidget {
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(
@ -46,21 +39,9 @@ class AvatarOrHash extends ConsumerWidget {
height: height, height: height,
child: avatar == null child: avatar == null
? fallback ?? box ? fallback ?? box
: Image( : Image.network(
image: CachedNetworkImage( avatar.toString(),
avatar! headers: headers,
.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 || room.metadata == null) return; if (controller.value.text.trim().isEmpty) return;
ref ref
.watch(RoomChatController.provider(room.metadata!.id).notifier) .watch(RoomChatController.provider(room).notifier)
.send( .send(
controller.value.formattedText, controller.value.formattedText,
relation: relatedMessage, relation: relatedMessage,
@ -94,6 +94,7 @@ 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,
@ -104,7 +105,7 @@ class ChatBox extends HookConsumerWidget {
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [], itemBuilder: (context) => [],
icon: Icon(Icons.add), icon: Icon(Icons.add),
// enabled: room.canSendDefaultMessages, TODO: Permissions check enabled: room.canSendDefaultMessages,
), ),
Expanded( Expanded(
child: FlutterTagger( child: FlutterTagger(
@ -125,13 +126,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,
@ -142,8 +143,7 @@ class ChatBox extends HookConsumerWidget {
), ),
), ),
IconButton( IconButton(
onPressed: send, onPressed: room.canSendDefaultMessages ? send : null,
// onPressed: room.canSendDefaultMessages ? send : null,
icon: Icon(Icons.send), icon: Icon(Icons.send),
), ),
], ],

View file

@ -2,19 +2,21 @@ 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:nexus/controllers/client_state_controller.dart"; import "package:matrix/matrix.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;
const Html(this.html, {super.key}); final Client client;
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(
@ -36,29 +38,35 @@ class Html extends ConsumerWidget {
) )
: null, : null,
"blockquote" => Quoted(Html(element.innerHtml)), "blockquote" => Quoted(Html(element.innerHtml, client: client)),
"a" => "a" =>
element.attributes["href"]?.mention == null element.attributes["href"]?.parseIdentifierIntoParts() == 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
: InlineCustomWidget( : Consumer(
child: Image.network( builder: (_, ref, _) => ref
Uri.parse(element.attributes["src"]!) .watch(
.mxcToHttps( ThumbnailController.provider(
ref.watch( ImageData(
ClientStateController.provider.select( uri: element.attributes["src"]!,
(value) => value?.homeserverUrl, height: height,
width: width,
),
), ),
) ??
"",
) )
.toString(), .when(
headers: ref.headers, data: (uri) {
if (uri == null) return SizedBox.shrink();
return InlineCustomWidget(
child: Image.network(
uri,
headers: client.headers,
errorBuilder: (_, error, _) => Text( errorBuilder: (_, error, _) => Text(
"Image Failed to Load", "Image Failed to Load",
style: TextStyle( style: TextStyle(
@ -72,7 +80,19 @@ 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:nexus/helpers/extensions/link_to_mention.dart"; import "package:matrix/matrix.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.mention ?? label, label.parseIdentifierIntoParts()?.primaryIdentifier ?? 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,8 +1,10 @@
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/models/room.dart"; import "package:nexus/helpers/extensions/get_headers.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 {
@ -20,7 +22,7 @@ class MemberList extends ConsumerWidget {
AppBar( AppBar(
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
leading: Icon(Icons.people), leading: Icon(Icons.people),
title: Text("Members (${members.length})"), title: Text("Members"),
actionsPadding: EdgeInsets.only(right: 4), actionsPadding: EdgeInsets.only(right: 4),
actions: [ actions: [
if (Scaffold.of(context).hasEndDrawer) if (Scaffold.of(context).hasEndDrawer)
@ -30,25 +32,30 @@ class MemberList extends ConsumerWidget {
), ),
], ],
), ),
...members.map( ...members
.where(
(membership) =>
membership.content["membership"] ==
Membership.join.name,
)
.map(
(member) => ListTile( (member) => ListTile(
onTap: () => showDialog( onTap: () {},
context: context,
builder: (context) =>
Dialog(child: Text("TODO: Open member popover")),
),
leading: AvatarOrHash( leading: AvatarOrHash(
Uri.tryParse(member.content["avatar_url"] ?? ""), ref
.watch(
AvatarController.provider(
member.content["avatar_url"].toString(),
),
)
.whenOrNull(data: (data) => data),
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,9 +1,11 @@
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/models/room.dart"; import "package:nexus/helpers/extensions/get_headers.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";
@ -21,10 +23,7 @@ class MentionOverlay extends ConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) => Padding(
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)),
@ -42,9 +41,9 @@ class MentionOverlay extends ConsumerWidget {
? members ? members
: members.where( : members.where(
(member) => (member) =>
member.authorId member.senderId.toLowerCase().contains(
.toLowerCase() query.toLowerCase(),
.contains(query.toLowerCase()) || ) ||
(member.content["displayname"] (member.content["displayname"]
as String?) as String?)
?.toLowerCase() ?.toLowerCase()
@ -56,18 +55,24 @@ class MentionOverlay extends ConsumerWidget {
.map( .map(
(member) => ListTile( (member) => ListTile(
leading: AvatarOrHash( leading: AvatarOrHash(
Uri.tryParse( ref
member.content["avatar_url"] ?? "", .watch(
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.authorId, member.senderId,
), ),
onTap: () => addTag( onTap: () => addTag(
id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.authorId})", id: member.senderId,
name: member.authorId name: member.senderId
.substring(1) .substring(1)
.split(":") .split(":")
.first, .first,
@ -77,45 +82,49 @@ class MentionOverlay extends ConsumerWidget {
.toList(), .toList(),
), ),
), ),
"#" => ListView( "#" =>
ref
.watch(RoomsController.provider)
.betterWhen(
data: (rooms) => ListView(
children: children:
(query.isEmpty (query.isEmpty
? rooms.values ? rooms
: rooms.values.where( : rooms.where(
(room) => (room.metadata?.name ?? "Unnamed Room") (room) => room.title.toLowerCase().contains(
.toLowerCase() query.toLowerCase(),
.contains(query.toLowerCase()), ),
)) ))
.map( .map(
(room) => ListTile( (room) => ListTile(
leading: AvatarOrHash( leading: AvatarOrHash(
room.metadata?.avatar, room.avatar,
room.metadata?.name ?? "Unnamed Room", room.title,
fallback: Icon(Icons.numbers), fallback: Icon(Icons.numbers),
headers: room.roomData.client.headers,
), ),
title: Text(room.metadata?.name ?? "Unnamed Room"), title: Text(room.title),
subtitle: room.metadata?.topic == null subtitle: room.roomData.topic.isEmpty
? null ? null
: Text(room.metadata!.topic!, maxLines: 1), : Text(room.roomData.topic, maxLines: 1),
onTap: () => addTag( onTap: () => addTag(
id: "[#${room.metadata?.name ?? "Unnamed Room"}](https://matrix.to/#/${room.metadata?.id})", id: "[#${room.roomData.getLocalizedDisplayname()}](https://matrix.to/#/${room.roomData.id})",
name: name:
(room.metadata?.canonicalAlias ?? (room.roomData.canonicalAlias.isEmpty
room.metadata?.id) ? room.roomData.id
?.substring(1) : room.roomData.canonicalAlias)
.substring(1)
.split(":") .split(":")
.first ?? .first,
"",
), ),
), ),
) )
.toList(), .toList(),
), ),
),
_ => Loading(), _ => Loading(),
}, },
), ),
), ),
); );
} }
}

View file

@ -1,16 +1,22 @@
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,
}); });
@ -31,18 +37,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/models/room.dart"; import "package:nexus/helpers/extensions/get_headers.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 Room room; final FullRoom 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,24 +25,21 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
Widget build(BuildContext context) => Appbar( Widget build(BuildContext context) => Appbar(
leading: isDesktop leading: isDesktop
? AvatarOrHash( ? AvatarOrHash(
room.metadata?.avatar, room.avatar,
room.metadata?.name ?? "Unnamed Rooms", room.title,
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.metadata?.name ?? "Unnamed Room", room.roomData.topic,
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(
@ -57,7 +54,7 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
onPressed: () => onOpenMemberList(context), onPressed: () => onOpenMemberList(context),
icon: Icon(Icons.people), icon: Icon(Icons.people),
), ),
RoomMenu(room), RoomMenu(room.roomData),
].toIList(), ],
); );
} }

View file

@ -9,8 +9,6 @@ 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";
@ -18,7 +16,6 @@ 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";
@ -27,6 +24,7 @@ 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;
@ -39,17 +37,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;
if (room == null || userId == null || room.metadata?.id == null) { return ref
.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...",
@ -57,35 +55,44 @@ class RoomChat extends HookConsumerWidget {
), ),
); );
} }
final controllerProvider = RoomChatController.provider(
final controllerProvider = RoomChatController.provider(room.metadata!.id); room.roomData,
);
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(leading: Icon(Icons.reply), title: Text("Reply")), child: ListTile(
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(leading: Icon(Icons.edit), title: Text("Edit")), child: ListTile(
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 = useTextEditingController(); final deleteReasonController =
useTextEditingController();
return AlertDialog( return AlertDialog(
title: Text("Delete Message"), title: Text("Delete Message"),
content: Column( content: Column(
@ -124,32 +131,18 @@ class RoomChat extends HookConsumerWidget {
}, },
), ),
), ),
child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), child: ListTile(
leading: Icon(Icons.delete),
title: Text("Delete"),
),
), ),
PopupMenuItem( PopupMenuItem(
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (context) => HookBuilder( builder: (context) => AlertDialog(
builder: (_) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text("Report"), title: Text("Report"),
content: Column( content: Text(
mainAxisSize: MainAxisSize.min, "Report this message to your server administrators, who can take action like banning that user or blocking that server from federating.",
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(
@ -158,23 +151,15 @@ class RoomChat extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
if (room.metadata == null) return; room.roomData.client.reportEvent(
client.reportEvent( room.roomData.id,
ReportRequest( message.id,
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(
@ -183,7 +168,6 @@ class RoomChat extends HookConsumerWidget {
), ),
), ),
]; ];
}
return Scaffold( return Scaffold(
appBar: RoomAppbar( appBar: RoomAppbar(
@ -205,11 +189,17 @@ class RoomChat extends HookConsumerWidget {
.watch(controllerProvider) .watch(controllerProvider)
.betterWhen( .betterWhen(
data: (controller) => Chat( data: (controller) => Chat(
currentUserId: userId, currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme).copyWith( theme: ChatTheme.fromThemeData(theme)
colors: ChatColors.fromThemeData(theme).copyWith( .copyWith(
primary: theme.colorScheme.primaryContainer, colors: ChatColors.fromThemeData(theme)
onPrimary: theme.colorScheme.onPrimaryContainer, .copyWith(
primary: theme
.colorScheme
.primaryContainer,
onPrimary: theme
.colorScheme
.onPrimaryContainer,
), ),
), ),
onMessageSecondaryTap: onMessageSecondaryTap:
@ -221,7 +211,8 @@ class RoomChat extends HookConsumerWidget {
}) => details?.globalPosition == null }) => details?.globalPosition == null
? null ? null
: context.showContextMenu( : context.showContextMenu(
globalPosition: details!.globalPosition, globalPosition:
details!.globalPosition,
children: getMessageOptions(message), children: getMessageOptions(message),
), ),
onMessageLongPress: onMessageLongPress:
@ -245,16 +236,21 @@ class RoomChat extends HookConsumerWidget {
showDialog( showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
backgroundColor: Colors.transparent, backgroundColor:
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.provider, CrossCacheController
.provider,
), ),
headers: ref.headers, headers: room
.roomData
.client
.headers,
), ),
), ),
), ),
@ -267,20 +263,18 @@ class RoomChat extends HookConsumerWidget {
chatAnimatedListBuilder: (_, itemBuilder) => chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList( ChatAnimatedList(
itemBuilder: itemBuilder, itemBuilder: itemBuilder,
onEndReached: room.hasMore onEndReached: notifier.loadOlder,
? notifier.loadOlder onStartReached: notifier.markRead,
: 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: () => replyToMessage.value = null, onDismiss: () =>
room: room, replyToMessage.value = null,
room: room.roomData,
), ),
// TODO: Polls
// customMessageBuilder: // customMessageBuilder:
// ( // (
// context, // context,
@ -323,6 +317,7 @@ 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(),
@ -400,7 +395,8 @@ 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) != null) { if (m.group(1) !=
null) {
return m.group(1)!; return m.group(1)!;
} }
@ -410,38 +406,48 @@ 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.textTheme.labelSmall, style: theme
.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: (_, message, isSentByMe) => linkPreviewBuilder:
LinkPreview( (_, message, isSentByMe) => LinkPreview(
text: message.text, text: message.text,
backgroundColor: isSentByMe backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary ? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow, : theme
.colorScheme
.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric( insidePadding: EdgeInsets.symmetric(
vertical: 8, vertical: 8,
horizontal: 16, horizontal: 16,
), ),
linkPreviewData: message.linkPreviewData, linkPreviewData:
onLinkPreviewDataFetched: (linkPreviewData) => message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) =>
notifier.updateMessage( notifier.updateMessage(
message, message,
message.copyWith( message.copyWith(
linkPreviewData: linkPreviewData, linkPreviewData:
linkPreviewData,
), ),
), ),
), ),
@ -455,15 +461,24 @@ 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: CachedNetworkImage( customImageProvider:
CachedNetworkImage(
message.source, message.source,
ref.watch(CrossCacheController.provider), ref.watch(
headers: ref.headers, CrossCacheController.provider,
), ),
errorBuilder: (context, error, stackTrace) => headers: room
.roomData
.client
.headers,
),
errorBuilder:
(context, error, stackTrace) =>
Center( Center(
child: Text( child: Text(
"Image Failed to Load", "Image Failed to Load",
@ -488,12 +503,16 @@ class RoomChat extends HookConsumerWidget {
onTap: () => showDialog( onTap: () => showDialog(
context: context, context: context,
builder: (_) => Dialog( builder: (_) => Dialog(
child: Text("TODO: Download Attachments"), child: Text(
"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,
@ -520,9 +539,8 @@ 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?.copyWith( style: theme.textTheme.labelSmall
color: Colors.grey, ?.copyWith(color: Colors.grey),
),
), ),
), ),
resolveUser: notifier.resolveUser, resolveUser: notifier.resolveUser,
@ -535,11 +553,15 @@ class RoomChat extends HookConsumerWidget {
), ),
if (memberListOpened.value == true && showMembersByDefault) if (memberListOpened.value == true && showMembersByDefault)
MemberList(room), MemberList(room.roomData),
], ],
), ),
endDrawer: showMembersByDefault ? null : MemberList(room), endDrawer: showMembersByDefault
? null
: MemberList(room.roomData),
);
},
); );
} }
} }

View file

@ -1,33 +1,38 @@
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/services.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:nexus/models/room.dart"; import "package:matrix/matrix.dart";
import "package:nexus/helpers/extensions/room_to_children.dart";
import "package:nexus/widgets/form_text_input.dart";
class RoomMenu extends ConsumerWidget { class RoomMenu extends StatelessWidget {
final Room room; final Room room;
final IList<Room> children; const RoomMenu(this.room, {super.key});
const RoomMenu(this.room, {this.children = const IList.empty(), super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
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 {
await client.markRead(room); final link = await room.matrixToInviteLink();
await Future.wait(children.map((child) => client.markRead(child))); await Clipboard.setData(ClipboardData(text: link.toString()));
}, },
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"),
@ -39,7 +44,7 @@ class RoomMenu extends ConsumerWidget {
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.metadata?.name ?? "Unnamed Room"}\"?", "Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?",
), ),
actions: [ actions: [
TextButton( TextButton(
@ -49,13 +54,10 @@ class RoomMenu extends ConsumerWidget {
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
final snackbar = ScaffoldMessenger.of(context).showSnackBar( final snackbar = ScaffoldMessenger.of(
SnackBar( context,
content: Text("Leaving room..."), ).showSnackBar(SnackBar(content: Text("Leaving room...")));
duration: Duration(days: 1), await room.leave();
),
);
await client.leaveRoom(room);
snackbar.close(); snackbar.close();
}, },
child: Text("Leave"), child: Text("Leave"),
@ -68,53 +70,53 @@ class RoomMenu extends ConsumerWidget {
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,3 +1,4 @@
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";
@ -5,6 +6,8 @@ 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";
@ -19,60 +22,58 @@ class Sidebar extends HookConsumerWidget {
final selectedSpaceProvider = KeyController.provider( final selectedSpaceProvider = KeyController.provider(
KeyController.spaceKey, KeyController.spaceKey,
); );
final selectedSpaceId = ref.watch(selectedSpaceProvider); final selectedSpace = ref.watch(selectedSpaceProvider);
final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier); final selectedSpaceNotifier = ref.watch(selectedSpaceProvider.notifier);
final selectedRoomController = KeyController.provider( final selectedRoomController = KeyController.provider(
KeyController.roomKey, KeyController.roomKey,
); );
final selectedRoomId = ref.watch(selectedRoomController); final selectedRoom = ref.watch(selectedRoomController);
final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier); final selectedRoomNotifier = 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: [
NavigationRail( ref
.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) {
selectedSpaceIdNotifier.set(spaces[value].id); selectedSpaceNotifier.set(spaces[value].id);
selectedRoomIdNotifier.set( selectedRoomNotifier.set(
spaces[value].children.firstOrNull?.metadata?.id, spaces[value].children.firstOrNull?.roomData.id,
); );
}, },
destinations: spaces destinations: spaces
.map( .map(
(space) => NavigationRailDestination( (space) => NavigationRailDestination(
icon: AvatarOrHash( icon: AvatarOrHash(
space.room?.metadata?.avatar, space.avatar,
fallback: space.icon == null ? null : Icon(space.icon), fallback: space.icon == null
? null
: Icon(space.icon),
space.title, space.title,
hasBadge: space.children.any( headers: space.client.headers,
(room) => room.metadata?.unreadMessages != 0, hasBadge:
), space.children.firstWhereOrNull(
badgeNumber: space.children.fold( (room) => room.roomData.hasNewMessages,
0, ) !=
(previousValue, room) => null,
previousValue +
(room.metadata?.unreadNotifications ?? 0),
),
), ),
label: Text(space.title), label: Text(space.title),
padding: EdgeInsets.only(top: 4), padding: EdgeInsets.only(top: 4),
@ -93,12 +94,14 @@ class Sidebar extends HookConsumerWidget {
context: context, context: context,
builder: (alertContext) => HookBuilder( builder: (alertContext) => HookBuilder(
builder: (_) { builder: (_) {
final roomAlias = useTextEditingController(); final roomAlias =
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.start, crossAxisAlignment:
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.",
@ -114,15 +117,19 @@ class Sidebar extends HookConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: Navigator.of(context).pop, onPressed: Navigator.of(
context,
).pop,
child: Text("Cancel"), child: Text("Cancel"),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.of(alertContext).pop(); Navigator.of(alertContext).pop();
final client = ref.watch( final client = await ref.watch(
ClientController.provider.notifier, ClientController
.provider
.future,
); );
if (context.mounted) { if (context.mounted) {
client.joinRoomWithSnackBars( client.joinRoomWithSnackBars(
@ -140,7 +147,9 @@ class Sidebar extends HookConsumerWidget {
), ),
), ),
child: ListTile( child: ListTile(
title: Text("Join an existing room (or space)"), title: Text(
"Join an existing room (or space)",
),
leading: Icon(Icons.numbers), leading: Icon(Icons.numbers),
), ),
), ),
@ -157,71 +166,83 @@ class Sidebar extends HookConsumerWidget {
IconButton( IconButton(
onPressed: () => showDialog( onPressed: () => showDialog(
context: context, context: context,
builder: (context) => AlertDialog(title: Text("To-do")), builder: (context) =>
AlertDialog(title: Text("To-do")),
), ),
icon: Icon(Icons.explore), icon: Icon(Icons.explore),
), ),
IconButton( IconButton(
onPressed: () => Navigator.of( onPressed: () => Navigator.of(context).push(
context, MaterialPageRoute(builder: (_) => SettingsPage()),
).push(MaterialPageRoute(builder: (_) => SettingsPage())), ),
icon: Icon(Icons.settings), icon: Icon(Icons.settings),
), ),
], ],
), ),
), ),
);
},
), ),
Expanded( Expanded(
child: Scaffold( child: ref
.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(
selectedSpace.room?.metadata?.avatar, space.avatar,
fallback: selectedSpace.icon == null fallback: space.icon == null
? null ? null
: Icon(selectedSpace.icon), : Icon(space.icon),
space.title,
selectedSpace.title, headers: space.client.headers,
), ),
title: Text( title: Text(
selectedSpace.title, space.title,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
actions: [ actions: [
if (selectedSpace.room != null) if (space.roomData != null) RoomMenu(space.roomData!),
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: selectedRoomIndex, selectedIndex: selectedIndex,
destinations: selectedSpace.children destinations: space.children
.map( .map(
(room) => NavigationRailDestination( (room) => NavigationRailDestination(
label: Text(room.metadata?.name ?? "Unnamed Room"), label: Text(room.title),
icon: AvatarOrHash( icon: AvatarOrHash(
room.metadata?.avatar, hasBadge: room.roomData.hasNewMessages,
hasBadge: room.metadata?.unreadMessages != 0, room.avatar,
badgeNumber: room.metadata?.unreadNotifications ?? 0, room.title,
room.metadata?.name ?? "Unnamed Room", fallback: selectedSpace == "dms"
fallback: selectedSpaceId == "dms"
? null ? null
: Icon(Icons.numbers), : Icon(Icons.numbers),
// space.client.headers, headers: space.client.headers,
), ),
), ),
) )
.toList(), .toList(),
onDestinationSelected: (value) => selectedRoomIdNotifier.set( onDestinationSelected: (value) => selectedRoomNotifier
selectedSpace.children[value].metadata?.id, .set(space.children[value].roomData.id),
),
), ),
);
},
), ),
), ),
], ],

View file

@ -1,16 +1,18 @@
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,
@ -60,10 +62,10 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
AvatarOrHash( Avatar(
Uri.tryParse(replyMessage.metadata?["avatarUrl"] ?? ""), userId: replyMessage.authorId,
replyMessage.metadata?["displayName"] ?? "", headers: headers,
height: 16, size: 16,
), ),
Flexible( Flexible(
child: Text( child: Text(
@ -102,10 +104,7 @@ class TopWidget extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
AvatarOrHash( Avatar(userId: message.authorId, headers: headers),
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,13 +20,11 @@ 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,
@ -47,7 +45,6 @@ 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,6 +13,8 @@ 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,6 +73,14 @@ 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:
@ -97,6 +105,14 @@ 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:
@ -137,6 +153,14 @@ 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:
@ -193,14 +217,6 @@ 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:
@ -378,21 +394,13 @@ packages:
source: hosted source: hosted
version: "11.1.0" version: "11.1.0"
ffi: ffi:
dependency: "direct main" dependency: transitive
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:
@ -533,6 +541,14 @@ 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:
@ -594,6 +610,14 @@ 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
@ -704,14 +728,6 @@ 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:
@ -728,6 +744,14 @@ 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:
@ -912,6 +936,14 @@ 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:
@ -928,6 +960,14 @@ 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:
@ -1120,14 +1160,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
quiver: random_string:
dependency: transitive dependency: transitive
description: description:
name: quiver name: random_string
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 sha256: "03b52435aae8cbdd1056cf91bfc5bf845e9706724dd35ae2e99fa14a1ef79d02"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "2.3.1"
riverpod: riverpod:
dependency: transitive dependency: transitive
description: description:
@ -1152,6 +1192,13 @@ 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:
@ -1208,6 +1255,14 @@ 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:
@ -1309,6 +1364,14 @@ 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:
@ -1349,6 +1412,30 @@ 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:
@ -1469,6 +1556,14 @@ 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:
@ -1581,6 +1676,15 @@ 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:
@ -1621,6 +1725,14 @@ 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,6 +12,11 @@ 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
@ -54,10 +59,14 @@ 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
@ -65,10 +74,9 @@ 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
ffi: ^2.1.5 rust_lib_nexus:
hooks: ^1.0.0 path: rust_builder
code_assets: ^1.0.0 # flutter_rust_bridge: 2.11.1
ffigen: ^20.1.1
dev_dependencies: dev_dependencies:
build_runner: ^2.4.11 build_runner: ^2.4.11

1
rust/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

4454
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
rust/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[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)'] }

1
rust/src/api/mod.rs Normal file
View file

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

10
rust/src/api/simple.rs Normal file
View file

@ -0,0 +1,10 @@
#[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();
}

35875
rust/src/frb_generated.rs Normal file

File diff suppressed because it is too large Load diff

2
rust/src/lib.rs Normal file
View file

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

29
rust_builder/.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# 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/

1
rust_builder/README.md Normal file
View file

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

9
rust_builder/android/.gitignore vendored Normal file
View file

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

View file

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

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

View file

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

4
rust_builder/cargokit/.gitignore vendored Normal file
View file

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

View file

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