Squashed commit of the following:
commit228ff1051fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 16:28:47 2026 -0400 Add load more button commit2451555479Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 16:17:21 2026 -0400 add reaction support commita28592d11eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 14:19:51 2026 -0400 change algorithm for deciding when to load more messages commit7016cc4205Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 14:17:01 2026 -0400 change wording on verify page message -> event commite4f091cb0fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 14:07:21 2026 -0400 Add GenericEventRenderer commit49d480d1e6Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 13:11:04 2026 -0400 remove extra backslash that was breaking link regex commit7850117cb6Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 12:49:01 2026 -0400 abstract ColorHash into its own extension commitfd5eaa2725Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 12:40:26 2026 -0400 fix audio player size commit1834ae2c5bAuthor: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 12:35:31 2026 -0400 fix timeline sorting commit13c2a4062bAuthor: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 12:26:41 2026 -0400 make it a little more efficient commit57cfad9f45Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 12:16:07 2026 -0400 Revert "temp isolate" This reverts commit34e6c07d8d. commit34e6c07d8dAuthor: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 12:16:01 2026 -0400 temp isolate commite00cd12bb9Author: Henry-Hiles <henry@henryhiles.com> Date: Thu May 21 11:24:18 2026 -0400 some performance improvements commite76c0aac16Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 16:30:26 2026 -0400 Slightly up padding for event preview commit8356719f8fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 16:10:47 2026 -0400 fix wrong colors on membership rendering commitd010faea4aAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 16:09:49 2026 -0400 grammar fixes for membership rendering commitcff580dee2Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 16:06:17 2026 -0400 general fixups, plus adding colors for names commitccd8513cdeAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 13:28:42 2026 -0400 reorganize files commit6e0dd8c33dAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 13:27:00 2026 -0400 update readme commitdf5040e06cAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 12:33:14 2026 -0400 remove selected room/space controllers commit0653961f9cAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 11:07:49 2026 -0400 reactive members controller, better caching, fixes #6, fixes #7 commite59505bd6eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:41:18 2026 -0400 Make room type into an enum commitfc6ca5b454Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:38:10 2026 -0400 Make message format an enum commit17a1af0b73Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:35:13 2026 -0400 change reply card color to not match message card commit740ab2fb9fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:26:33 2026 -0400 fix passing an mxc to expandableimage commit5a9e29be34Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:22:09 2026 -0400 ignore pointer for reply preview commitffdcc89de0Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:20:17 2026 -0400 fix incorrectly ellipsised messages commit8d3b94bc40Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:17:49 2026 -0400 fix showing the wrong user in reply preview commitf085d04f67Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 20 10:16:52 2026 -0400 Fix showing link previews in reply preview commitd746f40778Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 23:44:02 2026 -0400 fix nix build commit3aec4c3080Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 23:03:34 2026 -0400 lower padding for groups commit81aead26ccAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 23:02:40 2026 -0400 fix grouping logic commit922c624d4eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 22:57:04 2026 -0400 some indentation fixes in event renderer commit5c6cc1d584Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 22:55:29 2026 -0400 fix PMP rendering, add grouping commite7bcf956e3Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 22:46:36 2026 -0400 improve legibility of content parsing commitdbef2d709bAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 22:37:17 2026 -0400 fix double reply preview commit0da5e8beacAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 21:57:55 2026 -0400 fix overflow of reply preview commitc4255f340aAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 21:54:21 2026 -0400 Support for loading history and marking read commit200ce2285cAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 21:45:51 2026 -0400 limit size of loading indicator for link previews commit150de1a669Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 21:45:03 2026 -0400 Change scroll animation length commita72d696f49Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 21:44:03 2026 -0400 working reply rendering commit7761ca73fdAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 21:18:27 2026 -0400 working edits commit734e7f57dfAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 20:40:30 2026 -0400 add a todo for showing events waiting for a response, some wip code commit1305320a1aAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 20:17:41 2026 -0400 Implement theoretical code for rendering replies Waiting on Tulir's reply on why I don't get relatesTo and relationType back on DBEvents from sync commitbbd157a584Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:57:01 2026 -0400 use raw string for link regex commit94df2dc68fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:53:57 2026 -0400 add a WIP comment for location messages commit2344ed887dAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:45:39 2026 -0400 Add file card commit613e74ea33Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:25:41 2026 -0400 remove chat_page directory, move relevant files commit35f5d4e849Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:21:37 2026 -0400 refactor membership renderer into its own widget commit1cc2c87ae8Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:16:33 2026 -0400 rename render_event to event_renderer commit32aff5b4b1Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:12:59 2026 -0400 increase link preview padding commit551bec7982Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 19:11:23 2026 -0400 add custom audio player widget commit5c2f8fa014Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 18:47:30 2026 -0400 more reliable video playback commitce15add4e7Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 17:04:19 2026 -0400 turn up buffer size for video commit13f52a3989Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 16:37:48 2026 -0400 fix incorrect popover user for membership events commit8010c3467eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 16:37:18 2026 -0400 add video player, might need tweaking to get perfect commit1a4ef800c6Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 16:05:45 2026 -0400 placeholder widget for video support commit211c088df9Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 13:37:02 2026 -0400 fix extra memberships commitb3d1dc81b5Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 12:16:33 2026 -0400 add membership rendering commitf9b1960cf8Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 11:23:38 2026 -0400 support for m.emote msgtype commitb9e42ec51bAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 11:05:40 2026 -0400 constrain images to a max size commitb71ebe5feeAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 11:00:59 2026 -0400 fixup image rendering, prettier rendering for UTDs commite7ecae4606Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 10:56:05 2026 -0400 don't try to render redacted events commit6534e2d46eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 10:42:59 2026 -0400 change embed color commitdf491b2ed3Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 10:40:16 2026 -0400 fix decryption commitfee12cb94dAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 19 10:07:15 2026 -0400 fix up url embeds commit8aae2c29cbAuthor: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 14:52:53 2026 -0400 Working image rendering commit2aae141c27Author: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 14:46:22 2026 -0400 im drunk on the power of pattern matching this pr will be squash merged so commit messages dont really matter commitc9b5b3dda8Author: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 14:30:44 2026 -0400 various fixes commit061c280387Author: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 14:21:36 2026 -0400 make timestamps flexible not expanded commitcb20cb38fdAuthor: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 14:20:35 2026 -0400 text message rendering commitfd46dbda69Author: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 13:02:20 2026 -0400 fix defaults if power level event malformed commit22f9e61c7cAuthor: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 12:57:30 2026 -0400 fix power level logic commit5d1db60a9fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 10:54:58 2026 -0400 remove now unused method on room chat controller commit9303fee0deAuthor: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 10:49:31 2026 -0400 fix memberships constantly reloading commit46d7ec4202Author: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 10:25:13 2026 -0400 fix avatar parsing commita5ddce3d08Author: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 10:17:15 2026 -0400 fix padding commit14ec487bbeAuthor: Henry-Hiles <henry@henryhiles.com> Date: Mon May 18 10:12:53 2026 -0400 fix error handling in models commitcb22ed9822Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 22:24:41 2026 -0400 fix up bugs related to new architecture commit8d9645b460Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 21:42:14 2026 -0400 fix room watch commit292a219ed2Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 21:37:43 2026 -0400 pattern matching is awesome commitc65e8e0562Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 21:33:38 2026 -0400 fix displayname widget commit91d573e487Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 21:32:49 2026 -0400 fix constant refreshing commit161a9d2f13Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 21:25:04 2026 -0400 Displaying something now Just Event IDs so far commitcf5d1ad5d9Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 21:08:17 2026 -0400 building, but not yet working Still a lot to re-implement commit1fa050e7aeAuthor: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 18:41:34 2026 -0400 flesh out EventText a little more commit0be5336065Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 17:58:02 2026 -0400 add a placeholder EventText widget commita2e0b6bdb1Author: Henry-Hiles <henry@henryhiles.com> Date: Sun May 17 16:42:12 2026 -0400 add assertion for PowerLevelConfig.redaction commitad14f2207eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 21:21:33 2026 -0400 Fixes to power level controller commit49c09b3c35Author: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 16:22:49 2026 -0400 easy widgets ported to use new event format commit788900d852Author: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 15:35:45 2026 -0400 fix all helpers commitd0b148ad5bAuthor: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 15:24:05 2026 -0400 port all controllers to new event format commit94f0d9e346Author: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 11:33:38 2026 -0400 add reaction content type commit05b15c44ecAuthor: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 11:09:05 2026 -0400 add pinned events content type commit7e2c90381cAuthor: Henry-Hiles <henry@henryhiles.com> Date: Sat May 16 11:03:29 2026 -0400 add quite a few more content types commit17603f0d16Author: Henry-Hiles <henry@henryhiles.com> Date: Fri May 15 21:18:05 2026 -0400 add join rules event commite60e247093Author: Henry-Hiles <henry@henryhiles.com> Date: Fri May 15 20:59:30 2026 -0400 add create event commit3ce1f53bc4Author: Henry-Hiles <henry@henryhiles.com> Date: Fri May 15 20:14:17 2026 -0400 enum for event type commit3325ebcad7Author: Henry-Hiles <henry@henryhiles.com> Date: Fri May 15 20:07:08 2026 -0400 implement all msgtypes commit66356202c0Author: Henry-Hiles <henry@henryhiles.com> Date: Wed May 13 15:45:34 2026 -0400 good framework for content models commit6af56ccb3eAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 13 14:32:23 2026 -0400 Revert "possible way to union event" This reverts commitb3db9bea6f. commitb3db9bea6fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Wed May 13 14:32:12 2026 -0400 possible way to union event commitc520516d51Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 12 20:50:55 2026 -0400 treewide replace authorId with sender commit881c76359bAuthor: Henry-Hiles <henry@henryhiles.com> Date: Tue May 12 20:32:40 2026 -0400 custom link previews commitcee1298b62Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 12 20:08:55 2026 -0400 add back custom blurhashing commit8bdc1060d3Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 12 19:51:05 2026 -0400 remove flutter_chat commit25888144a6Author: Henry-Hiles <henry@henryhiles.com> Date: Tue May 12 19:27:23 2026 -0400 small fixups commitfb3b19a27fAuthor: Henry-Hiles <henry@henryhiles.com> Date: Sat May 9 17:48:20 2026 -0400 Reapply "WIP removal of new_events_controller" This reverts commit4dc16a5529.
This commit is contained in:
parent
bd1d5ea745
commit
3a280719d0
111 changed files with 3162 additions and 2366 deletions
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -6,7 +6,9 @@
|
||||||
"Gomuks",
|
"Gomuks",
|
||||||
"Homeserver",
|
"Homeserver",
|
||||||
"localpart",
|
"localpart",
|
||||||
|
"msgtype",
|
||||||
"muks",
|
"muks",
|
||||||
"prefs"
|
"prefs",
|
||||||
|
"unban"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
- [x] New logo
|
|
||||||
- [x] Move from the Dart SDK to the Gomuks Backend with Dart bindings: https://git.federated.nexus/Nexus/nexus/pulls/2
|
|
||||||
- [ ] Allow using remote Gomuks over websocket
|
|
||||||
- [ ] Platform Support
|
- [ ] Platform Support
|
||||||
- [x] Linux
|
- [x] Linux
|
||||||
- [ ] Windows (WIP)
|
- [ ] Windows (WIP)
|
||||||
|
|
@ -42,7 +39,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
- [x] `matrix:` Uri
|
- [x] `matrix:` Uri
|
||||||
- [x] Matrix.to link
|
- [x] Matrix.to link
|
||||||
- [ ] From space
|
- [ ] From space
|
||||||
- [ ] Exploring
|
- [ ] From directory
|
||||||
- [x] Leaving
|
- [x] Leaving
|
||||||
- [x] Subspaces
|
- [x] Subspaces
|
||||||
- [x] Messages
|
- [x] Messages
|
||||||
|
|
@ -116,6 +113,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
- [ ] Settings
|
- [ ] Settings
|
||||||
- [ ] Matrix: URIs vs Matrix.to links
|
- [ ] Matrix: URIs vs Matrix.to links
|
||||||
- [ ] Light/Dark mode
|
- [ ] Light/Dark mode
|
||||||
|
- [ ] Remote Gomuks instance
|
||||||
- [ ] SSD or CSD
|
- [ ] SSD or CSD
|
||||||
- [ ] Align your message bubbles to left or right
|
- [ ] Align your message bubbles to left or right
|
||||||
- [ ] Show media by default
|
- [ ] Show media by default
|
||||||
|
|
|
||||||
12
flake.lock
generated
12
flake.lock
generated
|
|
@ -5,11 +5,11 @@
|
||||||
"nixpkgs-lib": "nixpkgs-lib"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777988971,
|
"lastModified": 1778716662,
|
||||||
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
|
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
|
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -88,11 +88,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1778869304,
|
||||||
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,31 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
|
||||||
import "package:nexus/controllers/user_controller.dart";
|
import "package:nexus/controllers/user_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/models/configs/user_config.dart";
|
||||||
import "package:nexus/models/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/membership_status.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class AuthorController extends AsyncNotifier<Membership> {
|
class AuthorController extends AsyncNotifier<MembershipContent> {
|
||||||
final Message message;
|
final Event event;
|
||||||
AuthorController(this.message);
|
AuthorController(this.event);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Membership> build() async {
|
Future<MembershipContent> build() async {
|
||||||
final member = await ref.watch(
|
final member = await ref.watch(
|
||||||
UserController.provider(message.authorId).future,
|
UserController.provider(
|
||||||
|
UserConfig(roomId: event.roomId, userId: event.sender),
|
||||||
|
).future,
|
||||||
);
|
);
|
||||||
|
|
||||||
final pmp = message.metadata?["pmp"] == null
|
return MembershipContent(
|
||||||
? null
|
status: member.status,
|
||||||
: Membership.fromContent(
|
avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl,
|
||||||
IMap(message.metadata?["pmp"]),
|
displayName: event.pmp?.displayName ?? member.displayName,
|
||||||
message.authorId,
|
|
||||||
ref.watch(
|
|
||||||
ClientStateController.provider.select(
|
|
||||||
(value) => value?.homeserverUrl,
|
|
||||||
),
|
|
||||||
) ??
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
return Membership(
|
|
||||||
status: member?.status ?? MembershipStatus.leave,
|
|
||||||
avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl,
|
|
||||||
displayName:
|
|
||||||
pmp?.displayName ?? member?.displayName ?? message.authorId.localpart,
|
|
||||||
userId: message.authorId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<AuthorController, Membership, Message>(
|
AsyncNotifierProvider.family<AuthorController, MembershipContent, Event>(
|
||||||
AuthorController.new,
|
AuthorController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import "dart:developer";
|
|
||||||
import "dart:ffi";
|
import "dart:ffi";
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
import "dart:isolate";
|
import "dart:isolate";
|
||||||
import "package:collection/collection.dart";
|
import "dart:math";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:nexus/controllers/account_data_controller.dart";
|
import "package:nexus/controllers/account_data_controller.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
import "package:nexus/controllers/init_complete_controller.dart";
|
import "package:nexus/controllers/init_complete_controller.dart";
|
||||||
import "package:nexus/controllers/new_events_controller.dart";
|
|
||||||
import "package:nexus/controllers/rooms_controller.dart";
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
import "package:nexus/controllers/space_edges_controller.dart";
|
import "package:nexus/controllers/space_edges_controller.dart";
|
||||||
import "package:nexus/controllers/sync_status_controller.dart";
|
import "package:nexus/controllers/sync_status_controller.dart";
|
||||||
|
|
@ -17,6 +15,7 @@ import "package:nexus/controllers/top_level_spaces_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/models/client_state.dart";
|
import "package:nexus/models/client_state.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/paginate.dart";
|
import "package:nexus/models/paginate.dart";
|
||||||
import "package:nexus/models/requests/get_event_request.dart";
|
import "package:nexus/models/requests/get_event_request.dart";
|
||||||
|
|
@ -81,12 +80,8 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
case "send_complete":
|
case "send_complete":
|
||||||
final event = Event.fromJson(decodedMuksEvent["event"]);
|
final event = Event.fromJson(decodedMuksEvent["event"]);
|
||||||
|
|
||||||
if (event.type == "m.room.message") {
|
if (event.type == EventType.message.type) {
|
||||||
ref
|
// ref.watch(provider.notifier).addEvent(event); TODO
|
||||||
.watch(
|
|
||||||
NewEventsController.provider(event.roomId).notifier,
|
|
||||||
)
|
|
||||||
.add(IList([event]));
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "sync_complete":
|
case "sync_complete":
|
||||||
|
|
@ -127,9 +122,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
}
|
}
|
||||||
debugPrint("Finished handling $muksEventType...");
|
debugPrint("Finished handling $muksEventType...");
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
debugger();
|
if (kDebugMode) {
|
||||||
showError(error, stackTrace);
|
|
||||||
debugPrintStack(stackTrace: stackTrace, label: error.toString());
|
debugPrintStack(stackTrace: stackTrace, label: error.toString());
|
||||||
|
rethrow;
|
||||||
|
} else {
|
||||||
|
showError(error, stackTrace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -220,11 +218,6 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Event?> getEvent(GetEventRequest request) async {
|
Future<Event?> getEvent(GetEventRequest request) async {
|
||||||
final event = request.room.events.firstWhereOrNull(
|
|
||||||
(event) => event.eventId == request.eventId,
|
|
||||||
);
|
|
||||||
if (event != null) return event;
|
|
||||||
|
|
||||||
final json = await _sendCommand("get_event", request.toJson());
|
final json = await _sendCommand("get_event", request.toJson());
|
||||||
return json == null ? null : Event.fromJson(json);
|
return json == null ? null : Event.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
@ -232,8 +225,10 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<Paginate> paginate(PaginateRequest request) async =>
|
Future<Paginate> paginate(PaginateRequest request) async =>
|
||||||
Paginate.fromJson(await _sendCommand("paginate", request.toJson()));
|
Paginate.fromJson(await _sendCommand("paginate", request.toJson()));
|
||||||
|
|
||||||
Future<Profile> getProfile(String userId) async =>
|
Future<Profile> getProfile(String userId) async => Profile.fromJsonWithCatch({
|
||||||
Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
|
...(await _sendCommand("get_profile", {"user_id": userId})),
|
||||||
|
"id": userId,
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> reportEvent(ReportRequest request) =>
|
Future<void> reportEvent(ReportRequest request) =>
|
||||||
_sendCommand("report_event", request.toJson());
|
_sendCommand("report_event", request.toJson());
|
||||||
|
|
@ -242,9 +237,8 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
_sendCommand("set_membership", request.toJson());
|
_sendCommand("set_membership", request.toJson());
|
||||||
|
|
||||||
Future<void> markRead(Room room) async {
|
Future<void> markRead(Room room) async {
|
||||||
final event = room.events.firstWhereOrNull(
|
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||||
(event) => event.rowId == room.timeline.last.eventRowId,
|
final event = eventRowId == null ? null : room.events[eventRowId];
|
||||||
);
|
|
||||||
if (event == null || room.metadata == null) return;
|
if (event == null || room.metadata == null) return;
|
||||||
|
|
||||||
await _sendCommand("mark_read", {
|
await _sendCommand("mark_read", {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import "package:collection/collection.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/rooms_controller.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/get_event_request.dart";
|
import "package:nexus/models/requests/get_event_request.dart";
|
||||||
|
|
||||||
|
|
@ -9,8 +11,18 @@ class EventController extends AsyncNotifier<Event?> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Event?> build() async {
|
Future<Event?> build() async {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final room = ref.watch(
|
||||||
return await client.getEvent(request).onError((_, _) => null);
|
RoomsController.provider.select((value) => value[request.roomId]),
|
||||||
|
);
|
||||||
|
final event = room?.events.values.firstWhereOrNull(
|
||||||
|
(event) => event.eventId == request.eventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return event ??
|
||||||
|
await ref
|
||||||
|
.watch(ClientController.provider.notifier)
|
||||||
|
.getEvent(request)
|
||||||
|
.onError((_, _) => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,14 @@ class KeyController extends Notifier<String?> {
|
||||||
String? build() =>
|
String? build() =>
|
||||||
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
|
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
|
||||||
|
|
||||||
Future<void> set(String? id) async {
|
Future<void> set(String? value) async {
|
||||||
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
|
||||||
state = id;
|
state = value;
|
||||||
|
|
||||||
if (id == null) {
|
if (value == null) {
|
||||||
prefs.remove(key);
|
prefs.remove(key);
|
||||||
} else {
|
} else {
|
||||||
prefs.setString(key, id);
|
prefs.setString(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
lib/controllers/members_by_status_controller.dart
Normal file
32
lib/controllers/members_by_status_controller.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/members_controller.dart";
|
||||||
|
import "package:nexus/models/configs/members_by_status_config.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
|
class MembersByStatusController extends AsyncNotifier<ISet<Event>> {
|
||||||
|
final MembersByStatusConfig config;
|
||||||
|
MembersByStatusController(this.config);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ISet<Event>> build() => ref.watch(
|
||||||
|
MembersController.provider(config.roomId).selectAsync(
|
||||||
|
(members) => members
|
||||||
|
.where(
|
||||||
|
(membership) => switch (membership.content) {
|
||||||
|
MembershipContent(:final status) => config.status == status,
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toISet(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
AsyncNotifierProvider.family<
|
||||||
|
MembersByStatusController,
|
||||||
|
ISet<Event>,
|
||||||
|
MembersByStatusConfig
|
||||||
|
>(MembersByStatusController.new);
|
||||||
|
}
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/members_controller.dart";
|
|
||||||
import "package:nexus/models/membership.dart";
|
|
||||||
import "package:nexus/models/membership_status.dart";
|
|
||||||
|
|
||||||
class MembersByTypeController extends AsyncNotifier<IList<Membership>> {
|
|
||||||
final MembershipStatus status;
|
|
||||||
MembersByTypeController(this.status);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<IList<Membership>> build() => ref.watch(
|
|
||||||
MembersController.provider.selectAsync(
|
|
||||||
(members) =>
|
|
||||||
members.where((membership) => membership.status == status).toIList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
static final provider =
|
|
||||||
AsyncNotifierProvider.family<
|
|
||||||
MembersByTypeController,
|
|
||||||
IList<Membership>,
|
|
||||||
MembershipStatus
|
|
||||||
>(MembersByTypeController.new);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +1,46 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_controller.dart";
|
import "package:nexus/controllers/client_controller.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/membership.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/get_room_state_request.dart";
|
import "package:nexus/models/requests/get_room_state_request.dart";
|
||||||
|
|
||||||
class MembersController extends AsyncNotifier<IList<Membership>> {
|
class MembersController extends AsyncNotifier<ISet<Event>> {
|
||||||
@override
|
final String roomId;
|
||||||
Future<IList<Membership>> build() async {
|
MembersController(this.roomId);
|
||||||
final data = ref.watch(
|
|
||||||
SelectedRoomController.provider.select(
|
|
||||||
(value) => value?.metadata == null
|
|
||||||
? null
|
|
||||||
: (value!.metadata!.id, value.metadata!.hasMemberList),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (data == null) return const IList.empty();
|
|
||||||
|
|
||||||
final state = await ref
|
@override
|
||||||
|
Future<ISet<Event>> build() async {
|
||||||
|
final room = ref.watch(
|
||||||
|
RoomsController.provider.select((value) => value[roomId]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (room == null) return const ISet.empty();
|
||||||
|
|
||||||
|
if (!room.hasFetchedMembers) {
|
||||||
|
final fetchedState = await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.getRoomState(
|
.getRoomState(
|
||||||
GetRoomStateRequest(
|
GetRoomStateRequest(
|
||||||
roomId: data.$1,
|
roomId: roomId,
|
||||||
fetchMembers: data.$2 == false,
|
fetchMembers: room.metadata?.hasMemberList ?? true,
|
||||||
includeMembers: true,
|
includeMembers: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return state.nonNulls
|
await ref
|
||||||
.where((state) => state.type == "m.room.member")
|
.read(RoomsController.provider.notifier)
|
||||||
.map(
|
.addState(roomId, fetchedState, isMembers: true);
|
||||||
(membership) => Membership.fromContent(
|
|
||||||
membership.content,
|
|
||||||
membership.stateKey!,
|
|
||||||
ref.watch(
|
|
||||||
ClientStateController.provider.select(
|
|
||||||
(value) => value?.homeserverUrl,
|
|
||||||
),
|
|
||||||
) ??
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toIList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider =
|
return room.state[EventType.membership.type]?.values
|
||||||
AsyncNotifierProvider<MembersController, IList<Membership>>(
|
.map((rowId) => room.events[rowId])
|
||||||
MembersController.new,
|
.nonNulls
|
||||||
);
|
.toISet() ??
|
||||||
|
const ISet.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider = AsyncNotifierProvider.autoDispose
|
||||||
|
.family<MembersController, ISet<Event>, String>(MembersController.new);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
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_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/client_controller.dart";
|
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
|
||||||
import "package:nexus/models/configs/message_config.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 {
|
|
||||||
try {
|
|
||||||
final isEdit = config.event.relationType == "m.replace";
|
|
||||||
if ((isEdit && !config.includeEdits) || config.room.metadata == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final event = config.event.lastEditRowId == null
|
|
||||||
? config.event
|
|
||||||
: config.room.events.firstWhereOrNull(
|
|
||||||
(e) => e.rowId == config.event.lastEditRowId,
|
|
||||||
) ??
|
|
||||||
config.event;
|
|
||||||
|
|
||||||
final decrypted = (event.decrypted ?? event.content);
|
|
||||||
final type = (config.event.decryptedType ?? config.event.type);
|
|
||||||
final content = decrypted["m.new_content"] == null
|
|
||||||
? decrypted
|
|
||||||
: IMap(decrypted["m.new_content"]);
|
|
||||||
|
|
||||||
final homeserver = ref
|
|
||||||
.read(ClientStateController.provider)
|
|
||||||
?.homeserverUrl;
|
|
||||||
final source = homeserver == null || content["url"] == null
|
|
||||||
? "null"
|
|
||||||
: Uri.parse(content["url"]).mxcToHttps(homeserver).toString();
|
|
||||||
|
|
||||||
final metadata = {
|
|
||||||
"body": config.event.redactedBy == null
|
|
||||||
? (content["body"] ?? "")
|
|
||||||
: "Deleted Message",
|
|
||||||
"flashing": false,
|
|
||||||
"timelineId": event.timelineRowId,
|
|
||||||
"big": event.localContent?.bigEmoji == true,
|
|
||||||
"eventType": type,
|
|
||||||
"pmp": content["com.beeper.per_message_profile"],
|
|
||||||
"error": event.sendError,
|
|
||||||
"format": content["format"] ?? content["format"],
|
|
||||||
"editSource": event.localContent?.editSource ?? content["body"],
|
|
||||||
"txnId": config.event.transactionId,
|
|
||||||
};
|
|
||||||
|
|
||||||
final editedAt = event.relationType == "m.replace"
|
|
||||||
? event.timestamp
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ((event.redactedBy != null && !config.alwaysReturn) ||
|
|
||||||
(!config.includeEdits &&
|
|
||||||
(config.event.relationType == "m.replace"))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final replyId =
|
|
||||||
config.event.content["m.relates_to"]?["m.in_reply_to"]?["event_id"];
|
|
||||||
|
|
||||||
final reactionEvents = config.event.reactions.isEmpty && !isEdit
|
|
||||||
? null
|
|
||||||
: await ref
|
|
||||||
.watch(ClientController.provider.notifier)
|
|
||||||
.getRelatedEvents(
|
|
||||||
GetRelatedEventsRequest(
|
|
||||||
roomId: config.room.metadata!.id,
|
|
||||||
eventId:
|
|
||||||
(isEdit ? config.event.relatesTo : null) ??
|
|
||||||
config.event.eventId,
|
|
||||||
relationType: "m.annotation",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final reactions = reactionEvents
|
|
||||||
?.where((event) => event.redactedBy == null)
|
|
||||||
.fold<IMap<String, IList<String>>>(IMap(), (acc, event) {
|
|
||||||
final key = event.content["m.relates_to"]?["key"];
|
|
||||||
if (key == null) return acc;
|
|
||||||
|
|
||||||
return acc.update(
|
|
||||||
key,
|
|
||||||
(list) => list.add(event.authorId),
|
|
||||||
ifAbsent: () => IList([event.authorId]),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((key, value) => MapEntry(key, value.unlock))
|
|
||||||
.unlock;
|
|
||||||
|
|
||||||
final asText =
|
|
||||||
Message.text(
|
|
||||||
metadata: metadata,
|
|
||||||
id: config.event.eventId,
|
|
||||||
reactions: reactions,
|
|
||||||
authorId: event.authorId,
|
|
||||||
text: content["formatted_body"] ?? content["body"] ?? "",
|
|
||||||
replyToMessageId: replyId,
|
|
||||||
deliveredAt: config.event.timestamp,
|
|
||||||
editedAt: editedAt,
|
|
||||||
)
|
|
||||||
as TextMessage;
|
|
||||||
|
|
||||||
Message toSystemMessage(String content) => Message.system(
|
|
||||||
metadata: {...metadata, "body": content},
|
|
||||||
id: config.event.eventId,
|
|
||||||
reactions: reactions,
|
|
||||||
authorId: event.authorId,
|
|
||||||
deliveredAt: config.event.timestamp,
|
|
||||||
text: content,
|
|
||||||
);
|
|
||||||
|
|
||||||
return switch (type) {
|
|
||||||
"m.room.encrypted" => asText.copyWith(
|
|
||||||
text: "Unable to decrypt message.",
|
|
||||||
metadata: {...metadata, "body": "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"]) {
|
|
||||||
null || "m.image" => Message.image(
|
|
||||||
id: config.event.eventId,
|
|
||||||
authorId: event.authorId,
|
|
||||||
reactions: reactions,
|
|
||||||
source: source,
|
|
||||||
replyToMessageId: replyId,
|
|
||||||
metadata: metadata,
|
|
||||||
text: asText.text,
|
|
||||||
deliveredAt: config.event.timestamp,
|
|
||||||
blurhash: (content["info"] as Map?)?["xyz.amorgan.blurhash"],
|
|
||||||
),
|
|
||||||
"m.audio" || "m.file" => Message.file(
|
|
||||||
name: content["filename"].toString(),
|
|
||||||
size: content["info"]["size"],
|
|
||||||
metadata: metadata,
|
|
||||||
id: config.event.eventId,
|
|
||||||
reactions: reactions,
|
|
||||||
authorId: event.authorId,
|
|
||||||
source: source,
|
|
||||||
replyToMessageId: replyId,
|
|
||||||
deliveredAt: config.event.timestamp,
|
|
||||||
),
|
|
||||||
_ => asText,
|
|
||||||
},
|
|
||||||
"m.room.member" =>
|
|
||||||
content["membership"] == event.unsigned["prev_content"]?["membership"]
|
|
||||||
? null
|
|
||||||
: toSystemMessage(
|
|
||||||
"${content["displayname"] ?? event.stateKey} ${switch (content["membership"]) {
|
|
||||||
"invite" => "was invited to",
|
|
||||||
"join" => "joined",
|
|
||||||
"leave" => event.authorId == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
|
||||||
"ban" => "was banned from",
|
|
||||||
"knock" => "asked to join",
|
|
||||||
_ => "did something relating to",
|
|
||||||
}} the room. ${content["reason"] ?? ""}",
|
|
||||||
),
|
|
||||||
|
|
||||||
"m.room.server_acl" => toSystemMessage(
|
|
||||||
"${event.authorId} updated the server ban list.",
|
|
||||||
),
|
|
||||||
|
|
||||||
"m.room.redaction" =>
|
|
||||||
config.alwaysReturn
|
|
||||||
? asText.copyWith(
|
|
||||||
metadata: {
|
|
||||||
...(asText.metadata ?? {}),
|
|
||||||
"body": "Deleted Message",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
_ =>
|
|
||||||
config.alwaysReturn
|
|
||||||
? asText
|
|
||||||
: (
|
|
||||||
// Turn this on for debugging purposes
|
|
||||||
false
|
|
||||||
// ignore: dead_code
|
|
||||||
? Message.unsupported(
|
|
||||||
metadata: metadata,
|
|
||||||
reactions: reactions,
|
|
||||||
id: config.event.eventId,
|
|
||||||
authorId: event.authorId,
|
|
||||||
replyToMessageId: replyId,
|
|
||||||
)
|
|
||||||
: null),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
|
||||||
.autoDispose<MessageController, Message?, MessageConfig>(
|
|
||||||
MessageController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +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/configs/message_config.dart";
|
|
||||||
import "package:nexus/models/configs/messages_config.dart";
|
|
||||||
|
|
||||||
class MessagesController extends AsyncNotifier<IList<Message>> {
|
|
||||||
final MessagesConfig config;
|
|
||||||
MessagesController(this.config);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<IList<Message>> build() async => (await Future.wait(
|
|
||||||
config.events.map(
|
|
||||||
(event) => ref.watch(
|
|
||||||
MessageController.provider(
|
|
||||||
MessageConfig(event: event, room: config.room),
|
|
||||||
).future,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)).nonNulls.toIList();
|
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
|
||||||
.autoDispose<MessagesController, IList<Message>, MessagesConfig>(
|
|
||||||
MessagesController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
import "package:nexus/models/configs/power_level_config.dart";
|
import "package:nexus/models/configs/power_level_config.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
import "package:nexus/models/requests/membership_action.dart";
|
import "package:nexus/models/requests/membership_action.dart";
|
||||||
|
|
||||||
class PowerLevelController extends Notifier<bool> {
|
class PowerLevelController extends Notifier<bool> {
|
||||||
|
|
@ -11,58 +12,62 @@ class PowerLevelController extends Notifier<bool> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool build() {
|
bool build() {
|
||||||
final room = ref.watch(SelectedRoomController.provider);
|
if (config case EventPowerLevelConfig(:final eventType)) {
|
||||||
final event = room?.events.firstWhereOrNull(
|
assert(
|
||||||
(event) => event.rowId == room.state["m.room.power_levels"]?[""],
|
eventType != EventType.redaction,
|
||||||
|
"Checking power level for a redaction should use [PowerLevelConfig.redaction].",
|
||||||
);
|
);
|
||||||
final user = ref.watch(ClientStateController.provider)?.userId;
|
}
|
||||||
if (event == null || user == null) return false;
|
|
||||||
|
|
||||||
final users = (event.content["users"] as Map<String, dynamic>? ?? {});
|
final room = ref.watch(
|
||||||
final events = (event.content["events"] as Map<String, dynamic>? ?? {});
|
RoomsController.provider.select((value) => value[config.roomId]),
|
||||||
|
);
|
||||||
|
|
||||||
int powerLevelOf(String userId) => users.containsKey(userId)
|
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||||
? (users[userId] as int)
|
|
||||||
: (event.content["users_default"] as int? ?? 0);
|
final event = eventRowId == null ? null : room?.events[eventRowId];
|
||||||
|
final content = event?.content is PowerLevelsContent
|
||||||
|
? event!.content
|
||||||
|
: PowerLevelsContent();
|
||||||
|
final user = ref.watch(
|
||||||
|
ClientStateController.provider.select((value) => value?.userId),
|
||||||
|
);
|
||||||
|
if (user == null || content is! PowerLevelsContent) return false;
|
||||||
|
|
||||||
|
int powerLevelOf(String userId) =>
|
||||||
|
content.users[userId] ?? content.usersDefault;
|
||||||
|
|
||||||
final userLevel = powerLevelOf(user);
|
final userLevel = powerLevelOf(user);
|
||||||
final targetLevel = config.targetUser != null
|
|
||||||
? powerLevelOf(config.targetUser!)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (config.action != null) {
|
return switch (config) {
|
||||||
return switch (config.action!) {
|
EventPowerLevelConfig(:final eventType) =>
|
||||||
MembershipAction.invite =>
|
userLevel >= (content.events[eventType.type] ?? content.eventsDefault),
|
||||||
userLevel >= (event.content["invite"] as int? ?? 0),
|
|
||||||
|
MembershipActionPowerLevelConfig(:final action, :final targetUser) =>
|
||||||
|
switch (action) {
|
||||||
|
MembershipAction.invite => userLevel >= content.invite,
|
||||||
|
|
||||||
MembershipAction.kick =>
|
MembershipAction.kick =>
|
||||||
targetLevel != null &&
|
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
||||||
userLevel >= (event.content["kick"] as int? ?? 50) &&
|
|
||||||
userLevel > targetLevel,
|
|
||||||
|
|
||||||
MembershipAction.ban =>
|
MembershipAction.ban =>
|
||||||
targetLevel != null &&
|
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
||||||
userLevel >= (event.content["ban"] as int? ?? 50) &&
|
|
||||||
userLevel > targetLevel,
|
|
||||||
|
|
||||||
MembershipAction.unban =>
|
MembershipAction.unban => userLevel >= content.ban,
|
||||||
userLevel >= (event.content["ban"] as int? ?? 50),
|
},
|
||||||
|
|
||||||
|
StatePowerLevelConfig(:final eventType) =>
|
||||||
|
userLevel >= (content.events[eventType.type] ?? content.stateDefault),
|
||||||
|
|
||||||
|
RedactionPowerLevelConfig(:final targetUser) =>
|
||||||
|
userLevel >=
|
||||||
|
(targetUser == user
|
||||||
|
? (content.events[EventType.redaction.type] ??
|
||||||
|
content.eventsDefault)
|
||||||
|
: content.redact),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.eventType == "m.room.redaction") {
|
|
||||||
return userLevel >= (event.content["redact"] as int? ?? 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
final requiredLevel = events.containsKey(config.eventType)
|
|
||||||
? (events[config.eventType] as int)
|
|
||||||
: (config.isStateEvent
|
|
||||||
? (event.content["state_default"] as int? ?? 50)
|
|
||||||
: (event.content["events_default"] as int? ?? 0));
|
|
||||||
|
|
||||||
return userLevel >= requiredLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = NotifierProvider.autoDispose
|
static final provider = NotifierProvider.autoDispose
|
||||||
.family<PowerLevelController, bool, PowerLevelConfig>(
|
.family<PowerLevelController, bool, PowerLevelConfig>(
|
||||||
PowerLevelController.new,
|
PowerLevelController.new,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ class ProfileController extends AsyncNotifier<Profile> {
|
||||||
return client.getProfile(userId);
|
return client.getProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.autoDispose
|
static final provider =
|
||||||
.family<ProfileController, Profile, String>(ProfileController.new);
|
AsyncNotifierProvider.family<ProfileController, Profile, String>(
|
||||||
|
ProfileController.new,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
lib/controllers/reactions_controller.dart
Normal file
56
lib/controllers/reactions_controller.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/client_controller.dart";
|
||||||
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
|
import "package:nexus/models/configs/reactions_config.dart";
|
||||||
|
import "package:nexus/models/content/reaction.dart";
|
||||||
|
import "package:nexus/models/requests/get_related_events_request.dart";
|
||||||
|
|
||||||
|
class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> {
|
||||||
|
final ReactionsConfig config;
|
||||||
|
ReactionsController(this.config);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<IMap<String, IList<String>>> build() async {
|
||||||
|
final eventInfo = ref.watch(
|
||||||
|
RoomsController.provider.select((value) {
|
||||||
|
final event = value[config.roomId]?.events[config.eventRowId];
|
||||||
|
return event == null ? null : (event.eventId, event.reactions);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final reactionEvents = eventInfo?.$2.isNotEmpty == true
|
||||||
|
? await ref
|
||||||
|
.watch(ClientController.provider.notifier)
|
||||||
|
.getRelatedEvents(
|
||||||
|
GetRelatedEventsRequest(
|
||||||
|
roomId: config.roomId,
|
||||||
|
eventId: eventInfo!.$1,
|
||||||
|
relationType: "m.annotation",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return reactionEvents
|
||||||
|
?.where((event) => event.redactedBy == null)
|
||||||
|
.fold<IMap<String, IList<String>>>(IMap(), (acc, event) {
|
||||||
|
if (event.content case ReactionContent(:final key?)) {
|
||||||
|
return acc.update(
|
||||||
|
key,
|
||||||
|
(list) => list.add(event.sender),
|
||||||
|
ifAbsent: () => IList([event.sender]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}) ??
|
||||||
|
const IMap.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
AsyncNotifierProvider.family<
|
||||||
|
ReactionsController,
|
||||||
|
IMap<String, IList<String>>,
|
||||||
|
ReactionsConfig
|
||||||
|
>(ReactionsController.new);
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
|
import "dart:math";
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:fluttertagger/fluttertagger.dart";
|
import "package:fluttertagger/fluttertagger.dart";
|
||||||
import "package:nexus/controllers/client_controller.dart";
|
import "package:nexus/controllers/client_controller.dart";
|
||||||
import "package:nexus/controllers/message_controller.dart";
|
|
||||||
import "package:nexus/controllers/messages_controller.dart";
|
|
||||||
import "package:nexus/controllers/new_events_controller.dart";
|
|
||||||
import "package:nexus/controllers/rooms_controller.dart";
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
import "package:nexus/models/content/reaction.dart";
|
||||||
import "package:nexus/models/configs/messages_config.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/configs/message_config.dart";
|
|
||||||
import "package:nexus/models/requests/get_related_events_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/get_room_state_request.dart";
|
||||||
import "package:nexus/models/requests/paginate_request.dart";
|
import "package:nexus/models/requests/paginate_request.dart";
|
||||||
|
|
@ -21,203 +17,75 @@ import "package:nexus/models/requests/send_event_request.dart";
|
||||||
import "package:nexus/models/requests/send_message_request.dart";
|
import "package:nexus/models/requests/send_message_request.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
class RoomChatController extends AsyncNotifier<IList<Event>> {
|
||||||
final String roomId;
|
final String roomId;
|
||||||
RoomChatController(this.roomId);
|
RoomChatController(this.roomId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<InMemoryChatController> build() async {
|
Future<IList<Event>> build() async {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
var room = ref.read(RoomsController.provider)[roomId];
|
final room = ref.watch(
|
||||||
if (room == null) return InMemoryChatController();
|
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||||
|
);
|
||||||
|
if (room == null) return const IList.empty();
|
||||||
|
|
||||||
|
if (!room.hasFetchedState) {
|
||||||
final state = await client.getRoomState(
|
final state = await client.getRoomState(
|
||||||
GetRoomStateRequest(roomId: roomId),
|
GetRoomStateRequest(roomId: roomId),
|
||||||
);
|
);
|
||||||
|
|
||||||
ref
|
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
|
||||||
.read(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(),
|
|
||||||
);
|
|
||||||
|
|
||||||
room = ref.read(RoomsController.provider)[roomId];
|
|
||||||
if (room == null) return InMemoryChatController();
|
|
||||||
|
|
||||||
final messages = await ref.watch(
|
|
||||||
MessagesController.provider(
|
|
||||||
MessagesConfig(
|
|
||||||
room: room,
|
|
||||||
events: room.timeline
|
|
||||||
.map(
|
|
||||||
(timelineRowTuple) => room!.events.firstWhereOrNull(
|
|
||||||
(event) => event.rowId == timelineRowTuple.eventRowId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.nonNulls
|
|
||||||
.toIList(),
|
|
||||||
),
|
|
||||||
).future,
|
|
||||||
);
|
|
||||||
final controller = InMemoryChatController(messages: messages.toList());
|
|
||||||
|
|
||||||
ref.onDispose(
|
|
||||||
ref.listen(NewEventsController.provider(roomId), (_, next) async {
|
|
||||||
for (final event in next) {
|
|
||||||
if (event.type == "m.reaction") {
|
|
||||||
final message = controller.messages.firstWhereOrNull(
|
|
||||||
(message) =>
|
|
||||||
message.id == event.content["m.relates_to"]?["event_id"],
|
|
||||||
);
|
|
||||||
final key = event.content["m.relates_to"]?["key"];
|
|
||||||
if (message == null || key == null || !ref.mounted) return;
|
|
||||||
|
|
||||||
return await controller.updateMessage(
|
|
||||||
message,
|
|
||||||
message.copyWith(
|
|
||||||
reactions: IMap(message.reactions)
|
|
||||||
.update(
|
|
||||||
key,
|
|
||||||
(reactors) => [...reactors, event.authorId],
|
|
||||||
ifAbsent: () => [event.authorId],
|
|
||||||
)
|
|
||||||
.unlock,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type == "m.room.redaction") {
|
// While there are under 20 events, try to load more
|
||||||
final controller = await future;
|
// until there's no more or the conditions are met.
|
||||||
final redactsId = event.content["redacts"];
|
if (room.hasMore && room.timeline.length < 20) {
|
||||||
final originalMessage = controller.messages.firstWhereOrNull(
|
loadOlder();
|
||||||
(message) => message.id == redactsId,
|
|
||||||
);
|
|
||||||
if (!ref.mounted) return;
|
|
||||||
|
|
||||||
if (originalMessage != null) {
|
|
||||||
return await controller.removeMessage(originalMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final redacts = ref
|
return room.timeline
|
||||||
.read(SelectedRoomController.provider)
|
.toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0))
|
||||||
?.events
|
.map((entry) {
|
||||||
.firstWhere((event) => event.eventId == redactsId);
|
if (entry.value == null) return null;
|
||||||
|
|
||||||
if (redacts?.type == "m.reaction") {
|
final foundEvent = room.events[entry.value!];
|
||||||
final message = controller.messages.firstWhereOrNull(
|
|
||||||
(message) =>
|
|
||||||
message.id == redacts!.content["m.relates_to"]?["event_id"],
|
|
||||||
);
|
|
||||||
final key = redacts!.content["m.relates_to"]?["key"];
|
|
||||||
if (message == null || key == null || !ref.mounted) return;
|
|
||||||
|
|
||||||
return await controller.updateMessage(
|
final editedEvent =
|
||||||
message,
|
foundEvent == null || foundEvent.lastEditRowId == 0
|
||||||
message.copyWith(
|
|
||||||
reactions: IMap(message.reactions)
|
|
||||||
.update(
|
|
||||||
key,
|
|
||||||
(reactors) =>
|
|
||||||
IList(reactors).remove(redacts.authorId).unlock,
|
|
||||||
)
|
|
||||||
.where((_, value) => value.isNotEmpty)
|
|
||||||
.unlock,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final message = await ref.watch(
|
|
||||||
MessageController.provider(
|
|
||||||
MessageConfig(event: event, room: room!, includeEdits: true),
|
|
||||||
).future,
|
|
||||||
);
|
|
||||||
if (event.relationType == "m.replace") {
|
|
||||||
final controller = await future;
|
|
||||||
final oldMessage = controller.messages.firstWhereOrNull(
|
|
||||||
(element) => element.id == event.relatesTo,
|
|
||||||
);
|
|
||||||
if (oldMessage == null || message == null || !ref.mounted) return;
|
|
||||||
|
|
||||||
return await controller.updateMessage(
|
|
||||||
oldMessage,
|
|
||||||
message.copyWith(
|
|
||||||
id: oldMessage.id,
|
|
||||||
replyToMessageId: oldMessage.replyToMessageId,
|
|
||||||
metadata: {
|
|
||||||
...(oldMessage.metadata ?? {}),
|
|
||||||
...(message.metadata ?? {})
|
|
||||||
.toIMap()
|
|
||||||
.where((key, value) => value != null)
|
|
||||||
.unlock,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (message != null && ref.mounted) {
|
|
||||||
await insertMessage(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, weak: true).close,
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.onDispose(controller.dispose);
|
|
||||||
|
|
||||||
// While there are under 20 messages, try up to load more messages until theres no more or we have 20 messages.
|
|
||||||
for (var more = true; more == true && controller.messages.length < 20;) {
|
|
||||||
more = await loadOlder(controller);
|
|
||||||
}
|
|
||||||
|
|
||||||
return controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> insertMessage(Message message) async {
|
|
||||||
final controller = await future;
|
|
||||||
final oldMessage = message.metadata?["txnId"] == null
|
|
||||||
? null
|
? null
|
||||||
: controller.messages.firstWhereOrNull(
|
: room.events[foundEvent.lastEditRowId];
|
||||||
(element) =>
|
|
||||||
element.metadata?["txnId"] == message.metadata?["txnId"],
|
|
||||||
);
|
|
||||||
|
|
||||||
return oldMessage == null
|
return editedEvent == null
|
||||||
? controller.insertMessage(message)
|
? foundEvent
|
||||||
: controller.updateMessage(oldMessage, message);
|
: foundEvent?.copyWith(content: editedEvent.content);
|
||||||
|
})
|
||||||
|
.nonNulls
|
||||||
|
.toIList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteMessage(Message message, {String? reason}) => ref
|
Future<void> deleteMessage(Event event, {String? reason}) => ref
|
||||||
.watch(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.redactEvent(
|
.redactEvent(
|
||||||
RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason),
|
RedactEventRequest(
|
||||||
|
eventId: event.eventId,
|
||||||
|
roomId: roomId,
|
||||||
|
reason: reason,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<bool> loadOlder([InMemoryChatController? chatController]) async {
|
Future<bool> loadOlder() async {
|
||||||
|
final timelineKeys = ref
|
||||||
|
.read(RoomsController.provider.select((value) => value[roomId]))
|
||||||
|
?.timeline
|
||||||
|
.keys;
|
||||||
final response = await ref
|
final response = await ref
|
||||||
.watch(ClientController.provider.notifier)
|
.watch(ClientController.provider.notifier)
|
||||||
.paginate(
|
.paginate(
|
||||||
PaginateRequest(
|
PaginateRequest(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
maxTimelineId: ref
|
maxTimelineId: timelineKeys?.isNotEmpty == true
|
||||||
.read(RoomsController.provider)[roomId]
|
? timelineKeys?.reduce(min)
|
||||||
?.timeline
|
: null,
|
||||||
.firstOrNull
|
|
||||||
?.timelineRowId,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -226,42 +94,22 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
.update(
|
.update(
|
||||||
IMap({
|
IMap({
|
||||||
roomId: Room(
|
roomId: Room(
|
||||||
events: response.events.addAll(response.relatedEvents),
|
events: IMap.fromIterable(
|
||||||
hasMore: response.hasMore,
|
response.events.addAll(response.relatedEvents),
|
||||||
timeline: response.events
|
keyMapper: (event) => event.rowId,
|
||||||
.map(
|
valueMapper: (event) => event,
|
||||||
(event) => TimelineRowTuple(
|
),
|
||||||
timelineRowId: event.timelineRowId,
|
hasMore: response.hasMore,
|
||||||
eventRowId: event.rowId,
|
timeline: IMap.fromIterable(
|
||||||
|
response.events,
|
||||||
|
keyMapper: (event) => event.timelineRowId,
|
||||||
|
valueMapper: (event) => event.rowId,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
.toIList(),
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
const ISet.empty(),
|
const ISet.empty(),
|
||||||
addToNewEvents: false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final room = ref.read(RoomsController.provider)[roomId];
|
|
||||||
if (room != null) {
|
|
||||||
final messages = await ref.watch(
|
|
||||||
MessagesController.provider(
|
|
||||||
MessagesConfig(room: room, events: response.events.reversed),
|
|
||||||
).future,
|
|
||||||
);
|
|
||||||
|
|
||||||
final controller = chatController ?? await future;
|
|
||||||
await controller.insertAllMessages(
|
|
||||||
messages
|
|
||||||
.where(
|
|
||||||
(newMessage) => !controller.messages.any(
|
|
||||||
(message) => message.id == newMessage.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
index: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response.hasMore;
|
return response.hasMore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,7 +118,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
bool shouldMention = true,
|
bool shouldMention = true,
|
||||||
required IList<Tag> tags,
|
required IList<Tag> tags,
|
||||||
required RelationType relationType,
|
required RelationType relationType,
|
||||||
Message? relation,
|
Event? relation,
|
||||||
}) async {
|
}) async {
|
||||||
var taggedMessage = text;
|
var taggedMessage = text;
|
||||||
|
|
||||||
|
|
@ -285,7 +133,6 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
final room = ref.read(RoomsController.provider)[roomId];
|
|
||||||
final event = await client.sendMessage(
|
final event = await client.sendMessage(
|
||||||
SendMessageRequest(
|
SendMessageRequest(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|
@ -294,52 +141,46 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
if (shouldMention == true &&
|
if (shouldMention == true &&
|
||||||
relation != null &&
|
relation != null &&
|
||||||
relationType == RelationType.reply)
|
relationType == RelationType.reply)
|
||||||
relation.authorId,
|
relation.sender,
|
||||||
].toIList(),
|
].toIList(),
|
||||||
room: taggedMessage.contains("@room"),
|
room: taggedMessage.contains("@room"),
|
||||||
),
|
),
|
||||||
text: taggedMessage,
|
text: taggedMessage,
|
||||||
relation: relation == null
|
relation: relation == null
|
||||||
? null
|
? null
|
||||||
: Relation(eventId: relation.id, relationType: relationType),
|
: Relation(eventId: relation.eventId, relationType: relationType),
|
||||||
),
|
|
||||||
);
|
|
||||||
final message = room == null
|
|
||||||
? null
|
|
||||||
: await ref.watch(
|
|
||||||
MessageController.provider(
|
|
||||||
MessageConfig(room: room, event: event),
|
|
||||||
).future,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (message != null) insertMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> scrollToMessage(Message message) async {
|
|
||||||
final controller = await future;
|
|
||||||
Future<void> setFlashing(bool flashing) => controller.updateMessage(
|
|
||||||
message,
|
|
||||||
message.copyWith(
|
|
||||||
metadata: {...(message.metadata ?? {}), "flashing": flashing},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await setFlashing(true);
|
// TODO: Add new event to timeline whilst its sending
|
||||||
Timer(Duration(seconds: 1), () => setFlashing(false));
|
// ref
|
||||||
|
// .watch(RoomsController.provider.notifier)
|
||||||
return await controller.scrollToMessage(message.id);
|
// .update(
|
||||||
|
// {
|
||||||
|
// roomId: Room(
|
||||||
|
// events: [event].toIList(),
|
||||||
|
// timeline: [
|
||||||
|
// TimelineRowTuple(
|
||||||
|
// timelineRowId: event.timelineRowId,
|
||||||
|
// eventRowId: event.rowId,
|
||||||
|
// ),
|
||||||
|
// ].toIList(),
|
||||||
|
// ),
|
||||||
|
// }.toIMap(),
|
||||||
|
// const ISet.empty(),
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeReaction(
|
Future<void> removeReaction(
|
||||||
String reaction,
|
String reaction,
|
||||||
Message message,
|
Event event,
|
||||||
String userId,
|
String userId,
|
||||||
) async {
|
) async {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
final allReactionEvents = await client.getRelatedEvents(
|
final allReactionEvents = await client.getRelatedEvents(
|
||||||
GetRelatedEventsRequest(
|
GetRelatedEventsRequest(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
eventId: message.id,
|
eventId: event.eventId,
|
||||||
relationType: "m.annotation",
|
relationType: "m.annotation",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -349,9 +190,11 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
.toIList();
|
.toIList();
|
||||||
|
|
||||||
final reactionEvent = reactionEvents?.firstWhereOrNull(
|
final reactionEvent = reactionEvents?.firstWhereOrNull(
|
||||||
(event) =>
|
(event) => switch (event.content) {
|
||||||
event.authorId == userId &&
|
ReactionContent(:final key) =>
|
||||||
event.content["m.relates_to"]?["key"] == reaction,
|
key == reaction && event.sender == userId,
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reactionEvent != null) {
|
if (reactionEvent != null) {
|
||||||
|
|
@ -363,7 +206,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendReaction(String reaction, Message message) async {
|
Future<void> sendReaction(String reaction, Event event) async {
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
|
||||||
await client.sendEvent(
|
await client.sendEvent(
|
||||||
|
|
@ -372,7 +215,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
type: "m.reaction",
|
type: "m.reaction",
|
||||||
content: {
|
content: {
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
"event_id": message.id,
|
"event_id": event.eventId,
|
||||||
"rel_type": "m.annotation",
|
"rel_type": "m.annotation",
|
||||||
"key": reaction,
|
"key": reaction,
|
||||||
},
|
},
|
||||||
|
|
@ -384,7 +227,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, InMemoryChatController, String>(
|
.autoDispose<RoomChatController, IList<Event>, String>(
|
||||||
RoomChatController.new,
|
RoomChatController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import "package:collection/collection.dart";
|
import "dart:isolate";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/controllers/new_events_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
|
||||||
import "package:nexus/models/read_receipt.dart";
|
import "package:nexus/models/read_receipt.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
|
|
@ -11,55 +10,50 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
@override
|
@override
|
||||||
IMap<String, Room> build() => const IMap.empty();
|
IMap<String, Room> build() => const IMap.empty();
|
||||||
|
|
||||||
void update(
|
Future<void> addState(
|
||||||
IMap<String, Room> rooms,
|
String roomId,
|
||||||
ISet<String> leftRooms, {
|
IList<Event> state, {
|
||||||
bool addToNewEvents = true,
|
bool isMembers = false,
|
||||||
}) {
|
}) async => update(
|
||||||
final homeserver =
|
{
|
||||||
ref.watch(
|
roomId: Room(
|
||||||
ClientStateController.provider.select(
|
events: IMap.fromEntries(
|
||||||
(value) => value?.homeserverUrl,
|
state.map((event) => MapEntry(event.rowId, event)),
|
||||||
),
|
),
|
||||||
) ??
|
hasFetchedState: true,
|
||||||
"";
|
hasFetchedMembers: isMembers,
|
||||||
|
state: await Isolate.run(() {
|
||||||
|
final newState = state.fold(
|
||||||
|
const IMap<String, IMap<String, int>>.empty(),
|
||||||
|
(previousValue, stateEvent) => previousValue.add(
|
||||||
|
stateEvent.type,
|
||||||
|
(previousValue[stateEvent.type] ?? const IMap.empty()).add(
|
||||||
|
stateEvent.stateKey!,
|
||||||
|
stateEvent.rowId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return newState;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}.toIMap(),
|
||||||
|
const ISet.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
|
||||||
final merged = rooms.entries.fold(state, (acc, entry) {
|
final merged = rooms.entries.fold(state, (acc, entry) {
|
||||||
final roomId = entry.key;
|
final roomId = entry.key;
|
||||||
final incoming = entry.value;
|
final incoming = entry.value;
|
||||||
final existing = acc[roomId];
|
final existing = acc[roomId];
|
||||||
|
|
||||||
final events = existing?.events.updateById(
|
|
||||||
incoming.events,
|
|
||||||
(item) => item.eventId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addToNewEvents) {
|
|
||||||
ref
|
|
||||||
.watch(NewEventsController.provider(roomId).notifier)
|
|
||||||
.add(
|
|
||||||
incoming.timeline
|
|
||||||
.map(
|
|
||||||
(timelineTuple) => events?.firstWhereOrNull(
|
|
||||||
(event) => timelineTuple.eventRowId == event.rowId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.nonNulls
|
|
||||||
.toIList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc.add(
|
return acc.add(
|
||||||
roomId,
|
roomId,
|
||||||
existing?.copyWith(
|
existing?.copyWith(
|
||||||
hasMore: incoming.hasMore,
|
hasMore: incoming.hasMore,
|
||||||
metadata:
|
metadata: incoming.metadata ?? existing.metadata,
|
||||||
incoming.metadata?.copyWith(
|
events: incoming.events.isEmpty
|
||||||
avatar:
|
? existing.events
|
||||||
incoming.metadata?.avatar?.mxcToHttps(homeserver) ??
|
: existing.events.addAll(incoming.events),
|
||||||
existing.metadata?.avatar,
|
|
||||||
) ??
|
|
||||||
existing.metadata,
|
|
||||||
events: events!,
|
|
||||||
state: incoming.state.entries.fold(
|
state: incoming.state.entries.fold(
|
||||||
existing.state,
|
existing.state,
|
||||||
(previousValue, event) => previousValue.add(
|
(previousValue, event) => previousValue.add(
|
||||||
|
|
@ -69,15 +63,14 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
timeline:
|
reset: false,
|
||||||
(incoming.reset
|
hasFetchedMembers:
|
||||||
|
incoming.hasFetchedMembers || existing.hasFetchedMembers,
|
||||||
|
hasFetchedState:
|
||||||
|
incoming.hasFetchedState || existing.hasFetchedState,
|
||||||
|
timeline: (incoming.reset
|
||||||
? incoming.timeline
|
? incoming.timeline
|
||||||
: existing.timeline.updateById(
|
: existing.timeline.addAll(incoming.timeline)),
|
||||||
incoming.timeline,
|
|
||||||
(item) => item.timelineRowId,
|
|
||||||
))
|
|
||||||
.sortedBy((element) => element.timelineRowId)
|
|
||||||
.toIList(),
|
|
||||||
receipts: incoming.receipts.entries.fold(
|
receipts: incoming.receipts.entries.fold(
|
||||||
existing.receipts,
|
existing.receipts,
|
||||||
(receiptAcc, event) => receiptAcc.add(
|
(receiptAcc, event) => receiptAcc.add(
|
||||||
|
|
@ -88,11 +81,7 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
) ??
|
) ??
|
||||||
incoming.copyWith(
|
incoming,
|
||||||
metadata: incoming.metadata?.copyWith(
|
|
||||||
avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -100,6 +89,7 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
merged,
|
merged,
|
||||||
(acc, roomId) => acc.remove(roomId),
|
(acc, roomId) => acc.remove(roomId),
|
||||||
);
|
);
|
||||||
|
|
||||||
state = prunedList;
|
state = prunedList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/key_controller.dart";
|
|
||||||
import "package:nexus/controllers/selected_space_controller.dart";
|
|
||||||
import "package:nexus/models/room.dart";
|
|
||||||
|
|
||||||
class SelectedRoomController extends Notifier<Room?> {
|
|
||||||
@override
|
|
||||||
Room? build() {
|
|
||||||
final space = ref.watch(SelectedSpaceController.provider);
|
|
||||||
final selectedRoomId = ref.watch(
|
|
||||||
KeyController.provider(KeyController.roomKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
return space.children.firstWhereOrNull(
|
|
||||||
(room) => room.metadata?.id == selectedRoomId,
|
|
||||||
) ??
|
|
||||||
space.children.firstOrNull;
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<SelectedRoomController, Room?>(
|
|
||||||
SelectedRoomController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/key_controller.dart";
|
|
||||||
import "package:nexus/controllers/spaces_controller.dart";
|
|
||||||
import "package:nexus/models/space.dart";
|
|
||||||
|
|
||||||
class SelectedSpaceController extends Notifier<Space> {
|
|
||||||
@override
|
|
||||||
Space build() {
|
|
||||||
final spaces = ref.watch(SpacesController.provider);
|
|
||||||
final selectedSpaceId = ref.watch(
|
|
||||||
KeyController.provider(KeyController.spaceKey),
|
|
||||||
);
|
|
||||||
|
|
||||||
return spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ??
|
|
||||||
spaces.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
static final provider = NotifierProvider<SelectedSpaceController, Space>(
|
|
||||||
SelectedSpaceController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:http/http.dart";
|
import "package:http/http.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
import "package:nexus/controllers/header_controller.dart";
|
import "package:nexus/controllers/header_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||||
|
import "package:nexus/models/open_graph_data.dart";
|
||||||
|
|
||||||
class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
|
class UrlPreviewController extends AsyncNotifier<OpenGraphData?> {
|
||||||
final String link;
|
final String link;
|
||||||
UrlPreviewController(this.link);
|
UrlPreviewController(this.link);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LinkPreviewData?> build() async {
|
Future<OpenGraphData?> build() async {
|
||||||
final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl;
|
final homeserver = ref.watch(
|
||||||
|
ClientStateController.provider.select((value) => value?.homeserverUrl),
|
||||||
|
);
|
||||||
|
|
||||||
if (homeserver != null && !link.contains("matrix.to")) {
|
if (homeserver != null && !link.contains("matrix.to")) {
|
||||||
{
|
{
|
||||||
|
|
@ -25,27 +27,14 @@ class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final decodedValue = json.decode(response.body);
|
final decodedValue = json.decode(response.body);
|
||||||
|
if (decodedValue is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
final mxc = decodedValue["og:image"];
|
final mxc = decodedValue["og:image"];
|
||||||
final image = mxc == null
|
final image = mxc == null
|
||||||
? null
|
? null
|
||||||
: Uri.tryParse(mxc)?.mxcToHttps(homeserver);
|
: Uri.tryParse(mxc)?.mxcToHttps(homeserver);
|
||||||
|
|
||||||
return LinkPreviewData(
|
return OpenGraphData.fromJson(decodedValue).copyWith(imageUrl: image);
|
||||||
link: link,
|
|
||||||
title: decodedValue["og:title"],
|
|
||||||
description: decodedValue["og:description"],
|
|
||||||
image: image == null
|
|
||||||
? null
|
|
||||||
: ImagePreviewData(
|
|
||||||
url: image.toString(),
|
|
||||||
width:
|
|
||||||
(decodedValue["og:image:width"] as int?)?.toDouble() ??
|
|
||||||
0,
|
|
||||||
height:
|
|
||||||
(decodedValue["og:image:height"] as int?)?.toDouble() ??
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +43,7 @@ class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.autoDispose
|
static final provider = AsyncNotifierProvider.autoDispose
|
||||||
.family<UrlPreviewController, LinkPreviewData?, String>(
|
.family<UrlPreviewController, OpenGraphData?, String>(
|
||||||
UrlPreviewController.new,
|
UrlPreviewController.new,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,37 +4,44 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/members_controller.dart";
|
import "package:nexus/controllers/members_controller.dart";
|
||||||
import "package:nexus/controllers/profile_controller.dart";
|
import "package:nexus/controllers/profile_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/models/membership.dart";
|
import "package:nexus/models/configs/user_config.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/membership_status.dart";
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
|
||||||
class UserController extends AsyncNotifier<Membership?> {
|
class UserController extends AsyncNotifier<MembershipContent> {
|
||||||
final String userId;
|
final UserConfig config;
|
||||||
UserController(this.userId);
|
UserController(this.config);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Membership?> build() async {
|
Future<MembershipContent> build() async {
|
||||||
final member = await ref.watch(
|
final member = config.roomId == null
|
||||||
MembersController.provider.selectAsync(
|
? null
|
||||||
(value) =>
|
: await ref.watch(
|
||||||
value.firstWhereOrNull((membership) => membership.userId == userId),
|
MembersController.provider(config.roomId!).selectAsync(
|
||||||
|
(value) => value.firstWhereOrNull(
|
||||||
|
(membership) => membership.stateKey == config.userId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (member != null) return member;
|
if (member?.content case final MembershipContent content) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
final profile = await ref.watch(ProfileController.provider(userId).future);
|
final profile = await ref.watch(
|
||||||
return Membership(
|
ProfileController.provider(config.userId).future,
|
||||||
|
);
|
||||||
|
return MembershipContent(
|
||||||
status: MembershipStatus.leave,
|
status: MembershipStatus.leave,
|
||||||
avatarUrl: profile.avatarUrl == null
|
avatarUrl: profile.avatarUrl,
|
||||||
? null
|
displayName: profile.displayName ?? config.userId.localpart,
|
||||||
: Uri.tryParse(profile.avatarUrl!),
|
|
||||||
displayName: profile.displayName ?? userId.localpart,
|
|
||||||
userId: userId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<UserController, Membership?, String>(
|
AsyncNotifierProvider.family<
|
||||||
UserController.new,
|
UserController,
|
||||||
);
|
MembershipContent,
|
||||||
|
UserConfig
|
||||||
|
>(UserController.new);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import "package:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
|
import "package:nexus/models/membership_status.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class ViaController extends Notifier<String> {
|
class ViaController extends Notifier<String> {
|
||||||
|
|
@ -21,24 +25,30 @@ class ViaController extends Notifier<String> {
|
||||||
|
|
||||||
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
||||||
|
|
||||||
final powerLevels = room.events.firstWhereOrNull(
|
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
|
||||||
(event) => event.rowId == room.state["m.room.power_levels"]?[""],
|
final powerLevels = powerLevelsEventId == null
|
||||||
);
|
? null
|
||||||
|
: room.events[powerLevelsEventId];
|
||||||
|
|
||||||
for (final userId in IMap(powerLevels?.content["users"]).keys) {
|
if (powerLevels?.content case PowerLevelsContent(:final users)) {
|
||||||
|
for (final userId in users.keys) {
|
||||||
addUserId(userId);
|
addUserId(userId);
|
||||||
if (servers.length >= 5) break;
|
if (servers.length >= 5) break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final members = room.state["m.room.member"]?.values.toIList();
|
final members = room.state[EventType.membership.type]?.values.toIList();
|
||||||
for (var i = 0; servers.length < 5; i++) {
|
for (var i = 0; servers.length < 5; i++) {
|
||||||
final member = room.events.firstWhereOrNull(
|
final membershipEventId = members?.getOrNull(i);
|
||||||
(event) => event.rowId == members?.getOrNull(i),
|
final member = membershipEventId == null
|
||||||
);
|
? null
|
||||||
|
: room.events[membershipEventId];
|
||||||
|
|
||||||
if (member?.content["membership"] == "join") {
|
if (member?.content case MembershipContent(:final status)) {
|
||||||
|
if (status == MembershipStatus.join) {
|
||||||
addUserId(member?.stateKey);
|
addUserId(member?.stateKey);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (members?.getOrNull(i) == null) break;
|
if (members?.getOrNull(i) == null) break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
extension GetLocalpart on String {
|
extension GetLocalpart on String {
|
||||||
String get localpart => substring(1).split(":").first;
|
String get localpart => length > 1 ? substring(1).split(":").first : "?";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
||||||
import "package:nexus/models/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/widgets/chat_page/user_popover.dart";
|
import "package:nexus/widgets/user_popover.dart";
|
||||||
|
|
||||||
extension ShowUserPopover on BuildContext {
|
extension ShowUserPopover on BuildContext {
|
||||||
void showUserPopover(Membership member, {required Offset globalPosition}) =>
|
void showUserPopover(
|
||||||
showContextMenu(
|
MembershipContent member,
|
||||||
|
String userId, {
|
||||||
|
required Offset globalPosition,
|
||||||
|
}) => showContextMenu(
|
||||||
globalPosition: globalPosition,
|
globalPosition: globalPosition,
|
||||||
children: [
|
children: [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
enabled: false,
|
enabled: false,
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: IconTheme(data: IconThemeData(), child: UserPopover(member)),
|
child: IconTheme(
|
||||||
|
data: IconThemeData(),
|
||||||
|
child: UserPopover(member, userId),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
22
lib/helpers/extensions/size_to_string.dart
Normal file
22
lib/helpers/extensions/size_to_string.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
|
||||||
|
extension SizeToString on int {
|
||||||
|
String get sizeAsString {
|
||||||
|
const IListConst<String> suffixes = IListConst([
|
||||||
|
"B",
|
||||||
|
"KB",
|
||||||
|
"MB",
|
||||||
|
"GB",
|
||||||
|
"TB",
|
||||||
|
"PB",
|
||||||
|
]);
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
var size = toDouble();
|
||||||
|
while (size > 1024 && i < suffixes.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return "${size.toStringAsFixed(2)} ${suffixes[i]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/helpers/extensions/string_to_color.dart
Normal file
6
lib/helpers/extensions/string_to_color.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import "package:color_hash/color_hash.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
extension ToColor on String {
|
||||||
|
Color get colorHash => ColorHash(this, lightness: .7, saturation: .7).color;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import "dart:io";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:media_kit/media_kit.dart";
|
||||||
import "package:nexus/controllers/client_controller.dart";
|
import "package:nexus/controllers/client_controller.dart";
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
import "package:nexus/controllers/header_controller.dart";
|
import "package:nexus/controllers/header_controller.dart";
|
||||||
|
|
@ -56,6 +57,7 @@ void showError(Object error, [StackTrace? stackTrace]) {
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
|
||||||
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
|
|
|
||||||
15
lib/models/configs/members_by_status_config.dart
Normal file
15
lib/models/configs/members_by_status_config.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
part "members_by_status_config.freezed.dart";
|
||||||
|
part "members_by_status_config.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MembersByStatusConfig with _$MembersByStatusConfig {
|
||||||
|
const factory MembersByStatusConfig({
|
||||||
|
required String roomId,
|
||||||
|
required MembershipStatus status,
|
||||||
|
}) = _MembersByStatusConfig;
|
||||||
|
|
||||||
|
factory MembersByStatusConfig.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$MembersByStatusConfigFromJson(json);
|
||||||
|
}
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/room.dart";
|
|
||||||
part "message_config.freezed.dart";
|
|
||||||
part "message_config.g.dart";
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
abstract class MessageConfig with _$MessageConfig {
|
|
||||||
const MessageConfig._();
|
|
||||||
const factory MessageConfig({
|
|
||||||
@Default(false) bool alwaysReturn,
|
|
||||||
@Default(false) bool includeEdits,
|
|
||||||
required Room room,
|
|
||||||
required Event event,
|
|
||||||
}) = _MessageConfig;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
other.runtimeType == runtimeType &&
|
|
||||||
other is MessageConfig &&
|
|
||||||
other.event == event;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(runtimeType, event);
|
|
||||||
|
|
||||||
factory MessageConfig.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$MessageConfigFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
import "package:nexus/models/room.dart";
|
|
||||||
part "messages_config.freezed.dart";
|
|
||||||
part "messages_config.g.dart";
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
abstract class MessagesConfig with _$MessagesConfig {
|
|
||||||
const factory MessagesConfig({
|
|
||||||
required Room room,
|
|
||||||
required IList<Event> events,
|
|
||||||
}) = _MessagesConfig;
|
|
||||||
|
|
||||||
factory MessagesConfig.fromJson(Map<String, Object?> json) =>
|
|
||||||
_$MessagesConfigFromJson(json);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,28 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/requests/membership_action.dart";
|
import "package:nexus/models/requests/membership_action.dart";
|
||||||
part "power_level_config.freezed.dart";
|
part "power_level_config.freezed.dart";
|
||||||
part "power_level_config.g.dart";
|
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class PowerLevelConfig with _$PowerLevelConfig {
|
sealed class PowerLevelConfig with _$PowerLevelConfig {
|
||||||
const factory PowerLevelConfig({
|
const factory PowerLevelConfig({
|
||||||
@Default(false) bool isStateEvent,
|
required EventType eventType,
|
||||||
required String eventType,
|
required String roomId,
|
||||||
MembershipAction? action,
|
}) = EventPowerLevelConfig;
|
||||||
String? targetUser,
|
|
||||||
}) = _PowerLevelConfig;
|
|
||||||
|
|
||||||
factory PowerLevelConfig.fromJson(Map<String, Object?> json) =>
|
const factory PowerLevelConfig.membershipAction({
|
||||||
_$PowerLevelConfigFromJson(json);
|
required MembershipAction action,
|
||||||
|
required String targetUser,
|
||||||
|
required String roomId,
|
||||||
|
}) = MembershipActionPowerLevelConfig;
|
||||||
|
|
||||||
|
const factory PowerLevelConfig.state({
|
||||||
|
required EventType eventType,
|
||||||
|
required String roomId,
|
||||||
|
}) = StatePowerLevelConfig;
|
||||||
|
|
||||||
|
const factory PowerLevelConfig.redaction({
|
||||||
|
required String targetUser,
|
||||||
|
required String roomId,
|
||||||
|
}) = RedactionPowerLevelConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
lib/models/configs/reactions_config.dart
Normal file
14
lib/models/configs/reactions_config.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
part "reactions_config.freezed.dart";
|
||||||
|
part "reactions_config.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ReactionsConfig with _$ReactionsConfig {
|
||||||
|
const factory ReactionsConfig({
|
||||||
|
required String roomId,
|
||||||
|
required int eventRowId,
|
||||||
|
}) = _ReactionsConfig;
|
||||||
|
|
||||||
|
factory ReactionsConfig.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ReactionsConfigFromJson(json);
|
||||||
|
}
|
||||||
12
lib/models/configs/user_config.dart
Normal file
12
lib/models/configs/user_config.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
part "user_config.freezed.dart";
|
||||||
|
part "user_config.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class UserConfig with _$UserConfig {
|
||||||
|
const factory UserConfig({required String? roomId, required String userId}) =
|
||||||
|
_UserConfig;
|
||||||
|
|
||||||
|
factory UserConfig.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$UserConfigFromJson(json);
|
||||||
|
}
|
||||||
14
lib/models/content/avatar.dart
Normal file
14
lib/models/content/avatar.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/info/image.dart";
|
||||||
|
part "avatar.freezed.dart";
|
||||||
|
part "avatar.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class AvatarContent extends Content with _$AvatarContent {
|
||||||
|
AvatarContent._();
|
||||||
|
factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent;
|
||||||
|
|
||||||
|
factory AvatarContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$AvatarContentFromJson(json);
|
||||||
|
}
|
||||||
15
lib/models/content/canonical_alias.dart
Normal file
15
lib/models/content/canonical_alias.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "canonical_alias.freezed.dart";
|
||||||
|
part "canonical_alias.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CanonicalAliasContent extends Content
|
||||||
|
with _$CanonicalAliasContent {
|
||||||
|
CanonicalAliasContent._();
|
||||||
|
factory CanonicalAliasContent({String? alias, @Default([]) altAliases}) =
|
||||||
|
_CanonicalAliasContent;
|
||||||
|
|
||||||
|
factory CanonicalAliasContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$CanonicalAliasContentFromJson(json);
|
||||||
|
}
|
||||||
61
lib/models/content/content.dart
Normal file
61
lib/models/content/content.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:nexus/models/content/avatar.dart";
|
||||||
|
import "package:nexus/models/content/canonical_alias.dart";
|
||||||
|
import "package:nexus/models/content/create.dart";
|
||||||
|
import "package:nexus/models/content/encryption.dart";
|
||||||
|
import "package:nexus/models/content/join_rules.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/content/name.dart";
|
||||||
|
import "package:nexus/models/content/pinned_events.dart";
|
||||||
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
|
import "package:nexus/models/content/reaction.dart";
|
||||||
|
import "package:nexus/models/content/encrypted.dart";
|
||||||
|
import "package:nexus/models/content/redaction.dart";
|
||||||
|
import "package:nexus/models/content/server_acl.dart";
|
||||||
|
import "package:nexus/models/content/topic.dart";
|
||||||
|
|
||||||
|
class Content {
|
||||||
|
final Error? parseError;
|
||||||
|
Content({this.parseError});
|
||||||
|
|
||||||
|
factory Content.fromJson(Map<String, dynamic> json) => Content();
|
||||||
|
Map<String, dynamic> toJson() => {};
|
||||||
|
|
||||||
|
static Map<String, dynamic> readValue(Map<dynamic, dynamic> json, _) =>
|
||||||
|
json["decrypted"] ?? json["content"];
|
||||||
|
|
||||||
|
static Content fromEventJson(Map<String, dynamic> json, String type) {
|
||||||
|
try {
|
||||||
|
return (EventType.values
|
||||||
|
.firstWhereOrNull((eventType) => eventType.type == type)
|
||||||
|
?.contentFromJson ??
|
||||||
|
Content.fromJson)(json);
|
||||||
|
} catch (error) {
|
||||||
|
if (error is Error) return Content(parseError: error);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EventType {
|
||||||
|
encrypted("m.room.encrypted", EncryptedContent.fromJson),
|
||||||
|
redaction("m.room.redaction", RedactionContent.fromJson),
|
||||||
|
encryption("m.room.encryption", EncryptionContent.fromJson),
|
||||||
|
membership("m.room.member", MembershipContent.fromJson),
|
||||||
|
create("m.room.create", CreateContent.fromJson),
|
||||||
|
canonicalAlias("m.room.canonical_alias", CanonicalAliasContent.fromJson),
|
||||||
|
joinRules("m.room.join_rules", JoinRulesContent.fromJson),
|
||||||
|
powerLevels("m.room.power_levels", PowerLevelsContent.fromJson),
|
||||||
|
serverACL("m.room.server_acl", ServerACLContent.fromJson),
|
||||||
|
avatar("m.room.avatar", AvatarContent.fromJson),
|
||||||
|
topic("m.room.topic", TopicContent.fromJson),
|
||||||
|
name("m.room.name", NameContent.fromJson),
|
||||||
|
reaction("m.reaction", ReactionContent.fromJson),
|
||||||
|
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
|
||||||
|
message("m.room.message", MessageContent.fromJson);
|
||||||
|
|
||||||
|
final String type;
|
||||||
|
final Content Function(Map<String, dynamic> json) contentFromJson;
|
||||||
|
const EventType(this.type, this.contentFromJson);
|
||||||
|
}
|
||||||
41
lib/models/content/create.dart
Normal file
41
lib/models/content/create.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "create.freezed.dart";
|
||||||
|
part "create.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CreateContent extends Content with _$CreateContent {
|
||||||
|
CreateContent._();
|
||||||
|
factory CreateContent({
|
||||||
|
@JsonKey(name: "creator") String? creatorId,
|
||||||
|
|
||||||
|
@JsonKey(name: "additional_creators")
|
||||||
|
@Default(IList.empty())
|
||||||
|
IList<String> additionalCreatorIds,
|
||||||
|
|
||||||
|
PreviousRoom? predecessor,
|
||||||
|
|
||||||
|
@JsonKey(name: "m.federate") @Default(true) bool federated,
|
||||||
|
|
||||||
|
@Default("1") String roomVersion,
|
||||||
|
@JsonKey(unknownEnumValue: RoomType.room) RoomType? type,
|
||||||
|
}) = _CreateContent;
|
||||||
|
|
||||||
|
factory CreateContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$CreateContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoomType {
|
||||||
|
room,
|
||||||
|
@JsonValue("m.space")
|
||||||
|
space,
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PreviousRoom with _$PreviousRoom {
|
||||||
|
const factory PreviousRoom({required String roomId}) = _PreviousRoom;
|
||||||
|
|
||||||
|
factory PreviousRoom.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PreviousRoomFromJson(json);
|
||||||
|
}
|
||||||
13
lib/models/content/encrypted.dart
Normal file
13
lib/models/content/encrypted.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "encrypted.freezed.dart";
|
||||||
|
part "encrypted.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class EncryptedContent extends Content with _$EncryptedContent {
|
||||||
|
EncryptedContent._();
|
||||||
|
factory EncryptedContent() = _EncryptedContent;
|
||||||
|
|
||||||
|
factory EncryptedContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$EncryptedContentFromJson(json);
|
||||||
|
}
|
||||||
23
lib/models/content/encryption.dart
Normal file
23
lib/models/content/encryption.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "encryption.freezed.dart";
|
||||||
|
part "encryption.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class EncryptionContent extends Content with _$EncryptionContent {
|
||||||
|
EncryptionContent._();
|
||||||
|
factory EncryptionContent({
|
||||||
|
required String algorithm,
|
||||||
|
|
||||||
|
@JsonKey(name: "rotation_period_ms")
|
||||||
|
@Default(604800000)
|
||||||
|
int rotationPeriodMS,
|
||||||
|
|
||||||
|
@JsonKey(name: "rotation_period_msgs")
|
||||||
|
@Default(100)
|
||||||
|
int rotationPeriodMessages,
|
||||||
|
}) = _EncryptionContent;
|
||||||
|
|
||||||
|
factory EncryptionContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$EncryptionContentFromJson(json);
|
||||||
|
}
|
||||||
34
lib/models/content/join_rules.dart
Normal file
34
lib/models/content/join_rules.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/join_rule.dart";
|
||||||
|
part "join_rules.freezed.dart";
|
||||||
|
part "join_rules.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class JoinRulesContent extends Content with _$JoinRulesContent {
|
||||||
|
JoinRulesContent._();
|
||||||
|
factory JoinRulesContent({
|
||||||
|
required JoinRule joinRule,
|
||||||
|
@Default(IList.empty()) IList<AllowCondition> allow,
|
||||||
|
}) = _JoinRulesContent;
|
||||||
|
|
||||||
|
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$JoinRulesContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class AllowCondition with _$AllowCondition {
|
||||||
|
const factory AllowCondition({
|
||||||
|
String? roomId,
|
||||||
|
required AllowConditionType type,
|
||||||
|
}) = _AllowCondition;
|
||||||
|
|
||||||
|
factory AllowCondition.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$AllowConditionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AllowConditionType {
|
||||||
|
@JsonValue("m.room_membership")
|
||||||
|
membership,
|
||||||
|
}
|
||||||
19
lib/models/content/membership.dart
Normal file
19
lib/models/content/membership.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
part "membership.freezed.dart";
|
||||||
|
part "membership.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MembershipContent extends Content with _$MembershipContent {
|
||||||
|
MembershipContent._();
|
||||||
|
factory MembershipContent({
|
||||||
|
@JsonKey(name: "displayname") required String? displayName,
|
||||||
|
@JsonKey(name: "membership") required MembershipStatus status,
|
||||||
|
Uri? avatarUrl,
|
||||||
|
String? reason,
|
||||||
|
}) = _MembershipContent;
|
||||||
|
|
||||||
|
factory MembershipContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$MembershipContentFromJson(json);
|
||||||
|
}
|
||||||
92
lib/models/content/message.dart
Normal file
92
lib/models/content/message.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/info/audio.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/info/file.dart";
|
||||||
|
import "package:nexus/models/info/image.dart";
|
||||||
|
import "package:nexus/models/info/video.dart";
|
||||||
|
part "message.freezed.dart";
|
||||||
|
part "message.g.dart";
|
||||||
|
|
||||||
|
@Freezed(unionKey: "msgtype", fallbackUnion: "default")
|
||||||
|
abstract class MessageContent extends Content with _$MessageContent {
|
||||||
|
MessageContent._();
|
||||||
|
factory MessageContent({required String body}) = UnknownMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.text")
|
||||||
|
factory MessageContent.text({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
}) = TextMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.notice")
|
||||||
|
factory MessageContent.notice({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
}) = NoticeMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.emote")
|
||||||
|
factory MessageContent.emote({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
}) = EmoteMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.image")
|
||||||
|
factory MessageContent.image({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
// EncryptedFile? file
|
||||||
|
String? filename,
|
||||||
|
ImageInfo? info,
|
||||||
|
Uri? url,
|
||||||
|
}) = ImageMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.file")
|
||||||
|
factory MessageContent.file({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
// EncryptedFile? file
|
||||||
|
String? filename,
|
||||||
|
FileInfo? info,
|
||||||
|
Uri? url,
|
||||||
|
}) = FileMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.audio")
|
||||||
|
factory MessageContent.audio({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
// EncryptedFile? file
|
||||||
|
String? filename,
|
||||||
|
AudioInfo? info,
|
||||||
|
Uri? url,
|
||||||
|
}) = AudioMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.video")
|
||||||
|
factory MessageContent.video({
|
||||||
|
required String body,
|
||||||
|
MessageFormat? format,
|
||||||
|
String? formattedBody,
|
||||||
|
// EncryptedFile? file
|
||||||
|
String? filename,
|
||||||
|
VideoInfo? info,
|
||||||
|
Uri? url,
|
||||||
|
}) = VideoMessageContent;
|
||||||
|
|
||||||
|
@FreezedUnionValue("m.location")
|
||||||
|
factory MessageContent.location({required String body, required Uri geoUri}) =
|
||||||
|
LocationMessageContent;
|
||||||
|
|
||||||
|
factory MessageContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$MessageContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonEnum()
|
||||||
|
enum MessageFormat {
|
||||||
|
@JsonValue("org.matrix.custom.html")
|
||||||
|
html,
|
||||||
|
}
|
||||||
13
lib/models/content/name.dart
Normal file
13
lib/models/content/name.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "name.freezed.dart";
|
||||||
|
part "name.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class NameContent extends Content with _$NameContent {
|
||||||
|
NameContent._();
|
||||||
|
factory NameContent({required String name}) = _NameContent;
|
||||||
|
|
||||||
|
factory NameContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$NameContentFromJson(json);
|
||||||
|
}
|
||||||
15
lib/models/content/pinned_events.dart
Normal file
15
lib/models/content/pinned_events.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "pinned_events.freezed.dart";
|
||||||
|
part "pinned_events.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PinnedEventsContent extends Content with _$PinnedEventsContent {
|
||||||
|
PinnedEventsContent._();
|
||||||
|
factory PinnedEventsContent({@Default(IList.empty()) IList<String> pinned}) =
|
||||||
|
_PinnedEventsContent;
|
||||||
|
|
||||||
|
factory PinnedEventsContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PinnedEventsContentFromJson(json);
|
||||||
|
}
|
||||||
36
lib/models/content/power_levels.dart
Normal file
36
lib/models/content/power_levels.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "power_levels.freezed.dart";
|
||||||
|
part "power_levels.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PowerLevelsContent extends Content with _$PowerLevelsContent {
|
||||||
|
PowerLevelsContent._();
|
||||||
|
factory PowerLevelsContent({
|
||||||
|
@Default(IMap.empty()) IMap<String, int> events,
|
||||||
|
@Default(IMap.empty()) IMap<String, int> users,
|
||||||
|
Notifications? notifications,
|
||||||
|
@Default(50) int ban,
|
||||||
|
@Default(0) int eventsDefault,
|
||||||
|
@Default(0) int invite,
|
||||||
|
@Default(50) int kick,
|
||||||
|
@Default(50) int redact,
|
||||||
|
@Default(50) int stateDefault,
|
||||||
|
@Default(0) int usersDefault,
|
||||||
|
}) = _PowerLevelsContent;
|
||||||
|
|
||||||
|
factory PowerLevelsContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$PowerLevelsContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class Notifications with _$Notifications {
|
||||||
|
const factory Notifications({
|
||||||
|
@Default(50) int room,
|
||||||
|
@Default(IMapConst({})) IMap<String, int> other,
|
||||||
|
}) = _Notifications;
|
||||||
|
|
||||||
|
factory Notifications.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$NotificationsFromJson(json);
|
||||||
|
}
|
||||||
18
lib/models/content/reaction.dart
Normal file
18
lib/models/content/reaction.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "reaction.freezed.dart";
|
||||||
|
part "reaction.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ReactionContent extends Content with _$ReactionContent {
|
||||||
|
ReactionContent._();
|
||||||
|
static String? keyJsonFromJson(Map<dynamic, dynamic> json, String key) =>
|
||||||
|
json["m.relates_to"]?["key"];
|
||||||
|
|
||||||
|
factory ReactionContent({
|
||||||
|
@JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key,
|
||||||
|
}) = _ReactionContent;
|
||||||
|
|
||||||
|
factory ReactionContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ReactionContentFromJson(json);
|
||||||
|
}
|
||||||
14
lib/models/content/redaction.dart
Normal file
14
lib/models/content/redaction.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "redaction.freezed.dart";
|
||||||
|
part "redaction.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class RedactionContent extends Content with _$RedactionContent {
|
||||||
|
RedactionContent._();
|
||||||
|
factory RedactionContent({String? reason, String? redacts}) =
|
||||||
|
_RedactionContent;
|
||||||
|
|
||||||
|
factory RedactionContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$RedactionContentFromJson(json);
|
||||||
|
}
|
||||||
18
lib/models/content/server_acl.dart
Normal file
18
lib/models/content/server_acl.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "server_acl.freezed.dart";
|
||||||
|
part "server_acl.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ServerACLContent extends Content with _$ServerACLContent {
|
||||||
|
ServerACLContent._();
|
||||||
|
factory ServerACLContent({
|
||||||
|
@Default(IList.empty()) IList<String> allow,
|
||||||
|
@Default(IList.empty()) IList<String> deny,
|
||||||
|
@Default(true) allowIpLiterals,
|
||||||
|
}) = _ServerACLContent;
|
||||||
|
|
||||||
|
factory ServerACLContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ServerACLContentFromJson(json);
|
||||||
|
}
|
||||||
40
lib/models/content/topic.dart
Normal file
40
lib/models/content/topic.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
part "topic.freezed.dart";
|
||||||
|
part "topic.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class TopicContent extends Content with _$TopicContent {
|
||||||
|
TopicContent._();
|
||||||
|
factory TopicContent({
|
||||||
|
required String topic,
|
||||||
|
@JsonKey(name: "m.topic") TopicContentBlock? content,
|
||||||
|
}) = _TopicContent;
|
||||||
|
|
||||||
|
factory TopicContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$TopicContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class TopicContentBlock with _$TopicContentBlock {
|
||||||
|
factory TopicContentBlock({
|
||||||
|
@Default(IList.empty())
|
||||||
|
@JsonKey(name: "m.text")
|
||||||
|
IList<TextualRepresentation> representations,
|
||||||
|
}) = _TopicContentBlock;
|
||||||
|
|
||||||
|
factory TopicContentBlock.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$TopicContentBlockFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class TextualRepresentation with _$TextualRepresentation {
|
||||||
|
factory TextualRepresentation({
|
||||||
|
required String body,
|
||||||
|
@Default("text/plain") String mimetype,
|
||||||
|
}) = _TextualRepresentation;
|
||||||
|
|
||||||
|
factory TextualRepresentation.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$TextualRepresentationFromJson(json);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,69 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/epoch_date_time_converter.dart";
|
import "package:nexus/models/epoch_date_time_converter.dart";
|
||||||
|
import "package:nexus/models/profile.dart";
|
||||||
part "event.freezed.dart";
|
part "event.freezed.dart";
|
||||||
part "event.g.dart";
|
part "event.g.dart";
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Event with _$Event {
|
abstract class Event with _$Event {
|
||||||
|
static String typeJsonFromJson(Map<dynamic, dynamic> json, _) =>
|
||||||
|
json["decrypted_type"] ?? json["type"];
|
||||||
|
|
||||||
|
static Map<String, dynamic> getContentFromJson(Map<dynamic, dynamic> json) {
|
||||||
|
final content = json["decrypted"] ?? json["content"];
|
||||||
|
|
||||||
|
return content["m.new_content"] ?? content;
|
||||||
|
}
|
||||||
|
|
||||||
const factory Event({
|
const factory Event({
|
||||||
@JsonKey(name: "rowid") required int rowId,
|
@JsonKey(name: "rowid") required int rowId,
|
||||||
@JsonKey(name: "timeline_rowid") required int timelineRowId,
|
@JsonKey(name: "timeline_rowid") required int timelineRowId,
|
||||||
required String roomId,
|
required String roomId,
|
||||||
required String eventId,
|
required String eventId,
|
||||||
@JsonKey(name: "sender") required String authorId,
|
required String sender,
|
||||||
required String type,
|
@JsonKey(readValue: Event.typeJsonFromJson) required String type,
|
||||||
String? stateKey,
|
String? stateKey,
|
||||||
@EpochDateTimeConverter() required DateTime timestamp,
|
@EpochDateTimeConverter() required DateTime timestamp,
|
||||||
required IMap<String, dynamic> content,
|
|
||||||
IMap<String, dynamic>? decrypted,
|
|
||||||
String? decryptedType,
|
|
||||||
@Default(IMap.empty()) IMap<String, dynamic> unsigned,
|
@Default(IMap.empty()) IMap<String, dynamic> unsigned,
|
||||||
LocalContent? localContent,
|
LocalContent? localContent,
|
||||||
String? transactionId,
|
String? transactionId,
|
||||||
String? redactedBy,
|
String? redactedBy,
|
||||||
String? relatesTo,
|
String? relatesTo,
|
||||||
String? relationType,
|
String? relationType,
|
||||||
|
String? replyTo,
|
||||||
String? decryptionError,
|
String? decryptionError,
|
||||||
String? sendError,
|
String? sendError,
|
||||||
@Default(IMap.empty()) IMap<String, int> reactions,
|
@Default(IMap.empty()) IMap<String, int> reactions,
|
||||||
@JsonKey(name: "last_edit_rowid") int? lastEditRowId,
|
@JsonKey(name: "last_edit_rowid") @Default(0) int lastEditRowId,
|
||||||
@UnreadTypeConverter() UnreadType? unreadType,
|
@UnreadTypeConverter() UnreadType? unreadType,
|
||||||
|
Profile? pmp,
|
||||||
|
required Content content,
|
||||||
|
required Content? previousContent,
|
||||||
}) = _Event;
|
}) = _Event;
|
||||||
|
|
||||||
factory Event.fromJson(Map<String, Object?> json) => _$EventFromJson(json);
|
factory Event.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$EventFromJson(json).copyWith(
|
||||||
|
replyTo: getContentFromJson(
|
||||||
|
json,
|
||||||
|
)["m.relates_to"]?["m.in_reply_to"]?["event_id"],
|
||||||
|
pmp: json["content"]?["com.beeper.per_message_profile"] == null
|
||||||
|
? null
|
||||||
|
: Profile.fromJsonWithCatch(
|
||||||
|
json["content"]?["com.beeper.per_message_profile"],
|
||||||
|
),
|
||||||
|
content: Content.fromEventJson(
|
||||||
|
getContentFromJson(json),
|
||||||
|
json["decrypted_type"] ?? json["type"],
|
||||||
|
),
|
||||||
|
previousContent: json["unsigned"]?["prev_content"] == null
|
||||||
|
? null
|
||||||
|
: Content.fromEventJson(
|
||||||
|
json["unsigned"]?["prev_content"],
|
||||||
|
json["decrypted_type"] ?? json["type"],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|
|
||||||
17
lib/models/info/audio.dart
Normal file
17
lib/models/info/audio.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/ms_duration.dart";
|
||||||
|
part "audio.freezed.dart";
|
||||||
|
part "audio.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class AudioInfo with _$AudioInfo {
|
||||||
|
/// Information for images, [size] is in bytes.
|
||||||
|
const factory AudioInfo({
|
||||||
|
@MSDuration() Duration? duration,
|
||||||
|
@JsonKey(name: "mimetype") String? mimeType,
|
||||||
|
int? size,
|
||||||
|
}) = _AudioInfo;
|
||||||
|
|
||||||
|
factory AudioInfo.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$AudioInfoFromJson(json);
|
||||||
|
}
|
||||||
15
lib/models/info/file.dart
Normal file
15
lib/models/info/file.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
part "file.freezed.dart";
|
||||||
|
part "file.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class FileInfo with _$FileInfo {
|
||||||
|
/// Information for images, [size] is in bytes.
|
||||||
|
const factory FileInfo({
|
||||||
|
@JsonKey(name: "mimetype") String? mimeType,
|
||||||
|
int? size,
|
||||||
|
}) = _FileInfo;
|
||||||
|
|
||||||
|
factory FileInfo.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$FileInfoFromJson(json);
|
||||||
|
}
|
||||||
18
lib/models/info/image.dart
Normal file
18
lib/models/info/image.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
part "image.freezed.dart";
|
||||||
|
part "image.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ImageInfo with _$ImageInfo {
|
||||||
|
/// Information for images, [size] is in bytes.
|
||||||
|
const factory ImageInfo({
|
||||||
|
@JsonKey(name: "h") double? height,
|
||||||
|
@JsonKey(name: "w") double? width,
|
||||||
|
@JsonKey(name: "mimetype") String? mimeType,
|
||||||
|
@JsonKey(name: "xyz.amorgan.blurhash") String? blurHash,
|
||||||
|
int? size,
|
||||||
|
}) = _ImageInfo;
|
||||||
|
|
||||||
|
factory ImageInfo.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ImageInfoFromJson(json);
|
||||||
|
}
|
||||||
19
lib/models/info/video.dart
Normal file
19
lib/models/info/video.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/ms_duration.dart";
|
||||||
|
part "video.freezed.dart";
|
||||||
|
part "video.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class VideoInfo with _$VideoInfo {
|
||||||
|
/// Information for images, [size] is in bytes.
|
||||||
|
const factory VideoInfo({
|
||||||
|
@JsonKey(name: "h") int? height,
|
||||||
|
@JsonKey(name: "w") int? width,
|
||||||
|
@JsonKey(name: "mimetype") String? mimeType,
|
||||||
|
@MSDuration() Duration? duration,
|
||||||
|
int? size,
|
||||||
|
}) = _VideoInfo;
|
||||||
|
|
||||||
|
factory VideoInfo.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$VideoInfoFromJson(json);
|
||||||
|
}
|
||||||
4
lib/models/join_rule.dart
Normal file
4
lib/models/join_rule.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
|
enum JoinRule { public, knock, invite, private, restricted, knockRestricted }
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
|
||||||
import "package:nexus/models/membership_status.dart";
|
|
||||||
part "membership.freezed.dart";
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
abstract class Membership with _$Membership {
|
|
||||||
const Membership._();
|
|
||||||
const factory Membership({
|
|
||||||
required MembershipStatus status,
|
|
||||||
required Uri? avatarUrl,
|
|
||||||
required String displayName,
|
|
||||||
required String userId,
|
|
||||||
}) = _Membership;
|
|
||||||
|
|
||||||
factory Membership.fromContent(
|
|
||||||
IMap<String, dynamic> content,
|
|
||||||
String userId,
|
|
||||||
String homeserver,
|
|
||||||
) => Membership(
|
|
||||||
status: MembershipStatus.values.firstWhere(
|
|
||||||
(status) => status.name == content["membership"],
|
|
||||||
orElse: () => MembershipStatus.leave,
|
|
||||||
),
|
|
||||||
avatarUrl: Uri.tryParse(
|
|
||||||
content["avatar_url"] ?? "",
|
|
||||||
)?.mxcToHttps(homeserver),
|
|
||||||
userId: userId,
|
|
||||||
displayName: content["displayname"] ?? userId.substring(1).split(":").first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
@JsonEnum()
|
@JsonEnum()
|
||||||
enum MembershipStatus { leave, invite, ban, join }
|
enum MembershipStatus { leave, invite, ban, join, knock }
|
||||||
|
|
|
||||||
11
lib/models/ms_duration.dart
Normal file
11
lib/models/ms_duration.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
|
||||||
|
class MSDuration implements JsonConverter<Duration, int> {
|
||||||
|
const MSDuration();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration fromJson(int ms) => Duration(milliseconds: ms);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int toJson(Duration duration) => duration.inMilliseconds;
|
||||||
|
}
|
||||||
17
lib/models/open_graph_data.dart
Normal file
17
lib/models/open_graph_data.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
part "open_graph_data.freezed.dart";
|
||||||
|
part "open_graph_data.g.dart";
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class OpenGraphData with _$OpenGraphData {
|
||||||
|
const factory OpenGraphData({
|
||||||
|
@JsonKey(name: "og:title") required String? title,
|
||||||
|
@JsonKey(name: "og:description") required String? description,
|
||||||
|
@JsonKey(name: "og:image") required Uri? imageUrl,
|
||||||
|
@JsonKey(name: "og:image:width") required double? width,
|
||||||
|
@JsonKey(name: "og:image:height") required double? height,
|
||||||
|
}) = _OpenGraphData;
|
||||||
|
|
||||||
|
factory OpenGraphData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$OpenGraphDataFromJson(json);
|
||||||
|
}
|
||||||
|
|
@ -12,18 +12,28 @@ Object? readTimezone(Map<dynamic, dynamic> map, _) =>
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Profile with _$Profile {
|
abstract class Profile with _$Profile {
|
||||||
const factory Profile({
|
const factory Profile({
|
||||||
String? avatarUrl,
|
required String id,
|
||||||
|
String? parseError,
|
||||||
|
Uri? avatarUrl,
|
||||||
@JsonKey(name: "displayname") String? displayName,
|
@JsonKey(name: "displayname") String? displayName,
|
||||||
|
|
||||||
@JsonKey(readValue: readTimezone) String? timezone,
|
@JsonKey(readValue: readTimezone, name: "m.tz") String? timezone,
|
||||||
|
|
||||||
@Default(IList.empty())
|
@Default(IList.empty())
|
||||||
@JsonKey(readValue: readPronouns)
|
@JsonKey(readValue: readPronouns, name: "io.fsky.nyx.pronouns")
|
||||||
IList<Pronoun> pronouns,
|
IList<Pronoun> pronouns,
|
||||||
}) = _Profile;
|
}) = _Profile;
|
||||||
|
|
||||||
factory Profile.fromJson(Map<String, Object?> json) =>
|
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ProfileFromJson(json);
|
_$ProfileFromJson(json);
|
||||||
|
|
||||||
|
factory Profile.fromJsonWithCatch(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return Profile.fromJson(json);
|
||||||
|
} catch (error) {
|
||||||
|
return Profile(id: json["id"], parseError: error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,16 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/room.dart";
|
|
||||||
part "get_event_request.freezed.dart";
|
part "get_event_request.freezed.dart";
|
||||||
part "get_event_request.g.dart";
|
part "get_event_request.g.dart";
|
||||||
|
|
||||||
@Freezed(toJson: false)
|
@Freezed()
|
||||||
abstract class GetEventRequest with _$GetEventRequest {
|
abstract class GetEventRequest with _$GetEventRequest {
|
||||||
const GetEventRequest._();
|
const GetEventRequest._();
|
||||||
const factory GetEventRequest({
|
const factory GetEventRequest({
|
||||||
required Room room,
|
required String roomId,
|
||||||
required String eventId,
|
required String eventId,
|
||||||
@Default(false) bool unredact,
|
@Default(false) bool unredact,
|
||||||
}) = _GetEventRequest;
|
}) = _GetEventRequest;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
"room_id": room.metadata?.id,
|
|
||||||
"event_id": eventId,
|
|
||||||
"unredact": unredact,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
other.runtimeType == runtimeType &&
|
|
||||||
other is GetEventRequest &&
|
|
||||||
other.eventId == eventId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(runtimeType, eventId);
|
|
||||||
|
|
||||||
factory GetEventRequest.fromJson(Map<String, Object?> json) =>
|
factory GetEventRequest.fromJson(Map<String, Object?> json) =>
|
||||||
_$GetEventRequestFromJson(json);
|
_$GetEventRequestFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,29 +8,48 @@ part "room.g.dart";
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Room with _$Room {
|
abstract class Room with _$Room {
|
||||||
|
static IMap<int, int?> timelineTupleJsonToIMap(List<dynamic> json) =>
|
||||||
|
IMap.fromEntries(
|
||||||
|
json.map(
|
||||||
|
(timelineTuple) => MapEntry(
|
||||||
|
timelineTuple["timeline_rowid"],
|
||||||
|
timelineTuple["event_rowid"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static IMap<int, Event> eventsJsonToIMap(List<dynamic> json) =>
|
||||||
|
IMap.fromEntries(
|
||||||
|
json.map((eventJson) {
|
||||||
|
final event = Event.fromJson(eventJson);
|
||||||
|
return MapEntry(event.rowId, event);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// [timeline] is an IMap of timelineRowId to eventRowId
|
||||||
|
/// [events] is an IMap of eventRowId to event
|
||||||
const factory Room({
|
const factory Room({
|
||||||
@JsonKey(name: "meta") RoomMetadata? metadata,
|
@JsonKey(name: "meta") RoomMetadata? metadata,
|
||||||
@Default(IList.empty()) IList<TimelineRowTuple> timeline,
|
@Default(IMap.empty())
|
||||||
|
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
|
||||||
|
IMap<int, int?> timeline,
|
||||||
|
|
||||||
|
@Default(IMap.empty())
|
||||||
|
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
||||||
|
IMap<int, Event> events,
|
||||||
|
|
||||||
@Default(false) bool reset,
|
@Default(false) bool reset,
|
||||||
|
@Default(false) bool hasFetchedState,
|
||||||
|
@Default(false) bool hasFetchedMembers,
|
||||||
@Default(IMap.empty()) IMap<String, IMap<String, int>> state,
|
@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(IMap.empty()) IMap<String, IList<ReadReceipt>> receipts,
|
||||||
@Default(false) bool dismissNotifications,
|
@Default(false) bool dismissNotifications,
|
||||||
@Default(true) bool hasMore,
|
@Default(true) bool hasMore,
|
||||||
|
|
||||||
|
// required IMap<String, AccountData> accountData,
|
||||||
// required IList<Notification> notifications,
|
// required IList<Notification> notifications,
|
||||||
}) = _Room;
|
}) = _Room;
|
||||||
|
|
||||||
factory Room.fromJson(Map<String, Object?> json) => _$RoomFromJson(json);
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/init_complete_controller.dart";
|
import "package:nexus/controllers/init_complete_controller.dart";
|
||||||
|
import "package:nexus/controllers/key_controller.dart";
|
||||||
import "package:nexus/widgets/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/widgets/chat_page/sidebar.dart";
|
import "package:nexus/widgets/sidebar.dart";
|
||||||
import "package:nexus/widgets/chat_page/room_chat.dart";
|
import "package:nexus/widgets/room_chat.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
||||||
class ChatPage extends ConsumerWidget {
|
class ChatPage extends ConsumerWidget {
|
||||||
|
|
@ -15,22 +16,22 @@ class ChatPage extends ConsumerWidget {
|
||||||
final isDesktop = constraints.maxWidth > 650;
|
final isDesktop = constraints.maxWidth > 650;
|
||||||
final showMembersByDefault = constraints.maxWidth > 1000;
|
final showMembersByDefault = constraints.maxWidth > 1000;
|
||||||
final initComplete = ref.watch(InitCompleteController.provider);
|
final initComplete = ref.watch(InitCompleteController.provider);
|
||||||
|
final roomId = ref.watch(KeyController.provider(KeyController.roomKey));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: initComplete ? null : Appbar(),
|
appBar: initComplete ? null : Appbar(),
|
||||||
body: initComplete
|
body: initComplete
|
||||||
? Builder(
|
? Row(
|
||||||
builder: (context) => Row(
|
|
||||||
children: [
|
children: [
|
||||||
if (isDesktop) Sidebar(isDesktop: isDesktop),
|
if (isDesktop) Sidebar(isDesktop: isDesktop),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RoomChat(
|
child: RoomChat(
|
||||||
|
roomId: roomId,
|
||||||
isDesktop: isDesktop,
|
isDesktop: isDesktop,
|
||||||
showMembersByDefault: showMembersByDefault,
|
showMembersByDefault: showMembersByDefault,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: Center(
|
: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class VerifyPage extends HookConsumerWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.",
|
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
|
||||||
),
|
),
|
||||||
SizedBox(height: 12),
|
SizedBox(height: 12),
|
||||||
FormTextInput(
|
FormTextInput(
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import "package:color_hash/color_hash.dart";
|
||||||
import "package:cross_cache/cross_cache.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:flutter_riverpod/flutter_riverpod.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/helpers/extensions/get_headers.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 ConsumerWidget {
|
||||||
final Uri? avatar;
|
final Uri? avatar;
|
||||||
|
|
@ -28,6 +30,14 @@ class AvatarOrHash extends ConsumerWidget {
|
||||||
color: ColorHash(title).color,
|
color: ColorHash(title).color,
|
||||||
child: Center(child: Text(title.isEmpty ? "" : title[0])),
|
child: Center(child: Text(title.isEmpty ? "" : title[0])),
|
||||||
);
|
);
|
||||||
|
final parsedAvatar = avatar?.mxcToHttps(
|
||||||
|
ref.watch(
|
||||||
|
ClientStateController.provider.select(
|
||||||
|
(value) => value?.homeserverUrl,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
"",
|
||||||
|
);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: height,
|
width: height,
|
||||||
height: height,
|
height: height,
|
||||||
|
|
@ -42,11 +52,11 @@ class AvatarOrHash extends ConsumerWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: height,
|
width: height,
|
||||||
height: height,
|
height: height,
|
||||||
child: avatar == null
|
child: parsedAvatar == null
|
||||||
? fallback ?? box
|
? fallback ?? box
|
||||||
: Image(
|
: Image(
|
||||||
image: CachedNetworkImage(
|
image: CachedNetworkImage(
|
||||||
avatar.toString(),
|
parsedAvatar.toString(),
|
||||||
ref.watch(CrossCacheController.provider),
|
ref.watch(CrossCacheController.provider),
|
||||||
headers: ref.headers,
|
headers: ref.headers,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import "package:cross_cache/cross_cache.dart";
|
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:flyer_chat_image_message/flyer_chat_image_message.dart";
|
|
||||||
import "package:nexus/controllers/cross_cache_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/expandable_image.dart";
|
|
||||||
|
|
||||||
class ExpandableImageMessage extends ConsumerWidget {
|
|
||||||
final ImageMessage message;
|
|
||||||
final int index;
|
|
||||||
|
|
||||||
const ExpandableImageMessage(this.message, {required this.index, super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ExpandableImage(
|
|
||||||
message.source,
|
|
||||||
child: FlyerChatImageMessage(
|
|
||||||
customImageProvider: CachedNetworkImage(
|
|
||||||
message.source,
|
|
||||||
ref.watch(CrossCacheController.provider),
|
|
||||||
headers: ref.headers,
|
|
||||||
),
|
|
||||||
errorBuilder: (context, error, stackTrace) => Center(
|
|
||||||
child: Text(
|
|
||||||
"Image Failed to Load",
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
message: message,
|
|
||||||
index: index,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/user_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
|
||||||
|
|
||||||
class MentionChip extends ConsumerWidget {
|
|
||||||
final String content;
|
|
||||||
const MentionChip(this.content, {super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final membership = content.mention!.startsWith("@") == true
|
|
||||||
? ref
|
|
||||||
.watch(UserController.provider(content.mention!))
|
|
||||||
.whenOrNull(data: (data) => data)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTapUp: (details) {
|
|
||||||
content.mention;
|
|
||||||
if (membership != null) {
|
|
||||||
context.showUserPopover(
|
|
||||||
membership,
|
|
||||||
globalPosition: details.globalPosition,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Chip(
|
|
||||||
label: Text(
|
|
||||||
(membership == null ? null : "@${membership.displayName}") ??
|
|
||||||
content.mention!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +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/members_by_type_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
|
||||||
import "package:nexus/models/membership_status.dart";
|
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
|
||||||
|
|
||||||
class MemberList extends HookConsumerWidget {
|
|
||||||
const MemberList({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final status = useState(MembershipStatus.join);
|
|
||||||
final membersProvider = ref.watch(
|
|
||||||
MembersByTypeController.provider(status.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Drawer(
|
|
||||||
shape: Border(),
|
|
||||||
child: Column(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
AppBar(
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
leading: Icon(Icons.people),
|
|
||||||
title: Text("Members"),
|
|
||||||
actionsPadding: EdgeInsets.only(right: 4),
|
|
||||||
actions: [
|
|
||||||
if (Scaffold.of(context).hasEndDrawer)
|
|
||||||
IconButton(
|
|
||||||
onPressed: Scaffold.of(context).closeEndDrawer,
|
|
||||||
icon: Icon(Icons.close),
|
|
||||||
tooltip: "Close member list",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
FilterChip(
|
|
||||||
label: Text("Joined"),
|
|
||||||
onSelected: (value) => status.value = MembershipStatus.join,
|
|
||||||
selected: status.value == MembershipStatus.join,
|
|
||||||
),
|
|
||||||
FilterChip(
|
|
||||||
label: Text("Invited"),
|
|
||||||
onSelected: (value) => status.value = MembershipStatus.invite,
|
|
||||||
selected: status.value == MembershipStatus.invite,
|
|
||||||
),
|
|
||||||
FilterChip(
|
|
||||||
label: Text("Banned"),
|
|
||||||
onSelected: (value) => status.value = MembershipStatus.ban,
|
|
||||||
selected: status.value == MembershipStatus.ban,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
membersProvider.betterWhen(
|
|
||||||
data: (members) => Expanded(
|
|
||||||
child: ListView(
|
|
||||||
children: members
|
|
||||||
.map(
|
|
||||||
(member) => InkWell(
|
|
||||||
onTapUp: (details) => context.showUserPopover(
|
|
||||||
member,
|
|
||||||
globalPosition: details.globalPosition,
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: AvatarOrHash(
|
|
||||||
member.avatarUrl,
|
|
||||||
member.displayName,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
member.displayName,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
member.userId,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/event_controller.dart";
|
|
||||||
import "package:nexus/controllers/message_controller.dart";
|
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
|
||||||
import "package:nexus/models/configs/message_config.dart";
|
|
||||||
import "package:nexus/models/requests/get_event_request.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/html/quoted.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
|
|
||||||
|
|
||||||
typedef OnTapReply = void Function(Message message)?;
|
|
||||||
|
|
||||||
class ReplyWidget extends ConsumerWidget {
|
|
||||||
final Message message;
|
|
||||||
final bool alwaysShow;
|
|
||||||
final MessageGroupStatus? groupStatus;
|
|
||||||
final OnTapReply onTapReply;
|
|
||||||
const ReplyWidget(
|
|
||||||
this.message, {
|
|
||||||
required this.groupStatus,
|
|
||||||
this.onTapReply,
|
|
||||||
this.alwaysShow = false,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final room = ref.watch(SelectedRoomController.provider);
|
|
||||||
return message.replyToMessageId == null || room == null
|
|
||||||
? SizedBox.shrink()
|
|
||||||
: Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: 12),
|
|
||||||
child: Quoted(
|
|
||||||
ref
|
|
||||||
.watch(
|
|
||||||
EventController.provider(
|
|
||||||
GetEventRequest(
|
|
||||||
room: room,
|
|
||||||
eventId: message.replyToMessageId!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.betterWhen(
|
|
||||||
loading: () => Text("Fetching event..."),
|
|
||||||
data: (event) => event == null
|
|
||||||
? SizedBox.shrink()
|
|
||||||
: ref
|
|
||||||
.watch(
|
|
||||||
MessageController.provider(
|
|
||||||
MessageConfig(room: room, event: event),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.betterWhen(
|
|
||||||
loading: () => Text("Parsing message..."),
|
|
||||||
data: (replyMessage) {
|
|
||||||
if (replyMessage == null) {
|
|
||||||
return SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onTapReply?.call(replyMessage),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
MessageAvatar(replyMessage),
|
|
||||||
Flexible(
|
|
||||||
child: MessageDisplayname(
|
|
||||||
replyMessage,
|
|
||||||
clickable: false,
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.labelMedium
|
|
||||||
?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
replyMessage.metadata!["body"],
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelMedium,
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter/services.dart";
|
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_chat_ui/flutter_chat_ui.dart";
|
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
|
||||||
import "package:flyer_chat_file_message/flyer_chat_file_message.dart";
|
|
||||||
import "package:flyer_chat_system_message/flyer_chat_system_message.dart";
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/account_data_controller.dart";
|
|
||||||
import "package:nexus/controllers/client_controller.dart";
|
|
||||||
import "package:nexus/controllers/client_state_controller.dart";
|
|
||||||
import "package:nexus/controllers/power_level_controller.dart";
|
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
|
||||||
import "package:nexus/controllers/room_chat_controller.dart";
|
|
||||||
import "package:nexus/controllers/via_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
|
||||||
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
|
||||||
import "package:nexus/models/configs/power_level_config.dart";
|
|
||||||
import "package:nexus/models/relation_type.dart";
|
|
||||||
import "package:nexus/models/requests/report_request.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/composer/chat_box.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/emoji_picker_button.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/expandable_image_message.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/member_list.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/room_appbar.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/reply_widget.dart";
|
|
||||||
import "package:nexus/widgets/form_text_input.dart";
|
|
||||||
import "package:nexus/main.dart";
|
|
||||||
|
|
||||||
class RoomChat extends HookConsumerWidget {
|
|
||||||
final bool isDesktop;
|
|
||||||
final bool showMembersByDefault;
|
|
||||||
const RoomChat({
|
|
||||||
required this.isDesktop,
|
|
||||||
required this.showMembersByDefault,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final client = ref.watch(ClientController.provider.notifier);
|
|
||||||
final relatedMessage = useState<Message?>(null);
|
|
||||||
final memberListOpened = useState<bool>(showMembersByDefault);
|
|
||||||
final relationType = useState(RelationType.reply);
|
|
||||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
|
||||||
final roomId = ref.watch(
|
|
||||||
SelectedRoomController.provider.select((value) => value?.metadata?.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final danger = theme.colorScheme.error;
|
|
||||||
|
|
||||||
if (roomId == null || userId == null) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: RoomAppbar(
|
|
||||||
isDesktop: isDesktop,
|
|
||||||
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
|
|
||||||
onOpenMemberList: null,
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Text(
|
|
||||||
"Nothing to see here...",
|
|
||||||
style: theme.textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final controllerProvider = RoomChatController.provider(roomId);
|
|
||||||
final notifier = ref.watch(controllerProvider.notifier);
|
|
||||||
|
|
||||||
final composerNode = useFocusNode(
|
|
||||||
onKeyEvent: (_, event) {
|
|
||||||
if (event is KeyDownEvent &&
|
|
||||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
|
||||||
relatedMessage.value = null;
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
List<PopupMenuEntry> getMessageOptions(Message message) {
|
|
||||||
final isSentByMe = message.authorId == userId;
|
|
||||||
return [
|
|
||||||
if (ref.watch(
|
|
||||||
PowerLevelController.provider(
|
|
||||||
PowerLevelConfig(eventType: "m.reaction"),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
PopupMenuItem(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
...{
|
|
||||||
...ref.watch(
|
|
||||||
AccountDataController.provider.select(
|
|
||||||
(value) => IList(
|
|
||||||
value["m.recent_emoji"]?.content["recent_emoji"] ??
|
|
||||||
[],
|
|
||||||
).map((entry) => entry["emoji"]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"👍",
|
|
||||||
"🤣",
|
|
||||||
"😭",
|
|
||||||
"🤔",
|
|
||||||
}
|
|
||||||
.toIList()
|
|
||||||
.sublist(0, 4)
|
|
||||||
.map(
|
|
||||||
(emoji) => IconButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
await notifier
|
|
||||||
.sendReaction(emoji, message)
|
|
||||||
.onError(showError);
|
|
||||||
},
|
|
||||||
icon: Text(emoji),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
EmojiPickerButton(
|
|
||||||
context: context,
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
onSelection: (emoji) =>
|
|
||||||
notifier.sendReaction(emoji, message).onError(showError),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (ref.watch(
|
|
||||||
PowerLevelController.provider(
|
|
||||||
PowerLevelConfig(eventType: "m.room.message"),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () {
|
|
||||||
relatedMessage.value = message;
|
|
||||||
relationType.value = RelationType.reply;
|
|
||||||
composerNode.requestFocus();
|
|
||||||
},
|
|
||||||
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
|
|
||||||
),
|
|
||||||
if (message is TextMessage && isSentByMe)
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () {
|
|
||||||
relatedMessage.value = message;
|
|
||||||
relationType.value = RelationType.edit;
|
|
||||||
composerNode.requestFocus();
|
|
||||||
},
|
|
||||||
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () async {
|
|
||||||
final room = ref.watch(SelectedRoomController.provider);
|
|
||||||
if (room == null) return;
|
|
||||||
|
|
||||||
final vias = ref.watch(ViaController.provider(room));
|
|
||||||
|
|
||||||
await Clipboard.setData(
|
|
||||||
ClipboardData(
|
|
||||||
text:
|
|
||||||
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${message.id}$vias)",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
|
|
||||||
),
|
|
||||||
if (ref.watch(
|
|
||||||
PowerLevelController.provider(
|
|
||||||
PowerLevelConfig(eventType: "m.room.redaction"),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => HookBuilder(
|
|
||||||
builder: (_) {
|
|
||||||
final deleteReasonController = useTextEditingController();
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text("Delete Message"),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"Are you sure you want to delete this message? This can not be reversed.",
|
|
||||||
),
|
|
||||||
SizedBox(height: 12),
|
|
||||||
FormTextInput(
|
|
||||||
required: false,
|
|
||||||
capitalize: true,
|
|
||||||
controller: deleteReasonController,
|
|
||||||
title: "Reason for deletion (optional)",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
child: Text("Cancel"),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
await notifier
|
|
||||||
.deleteMessage(
|
|
||||||
message,
|
|
||||||
reason: deleteReasonController.text,
|
|
||||||
)
|
|
||||||
.onError(showError);
|
|
||||||
},
|
|
||||||
child: Text("Delete"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.delete, color: danger),
|
|
||||||
title: Text("Delete", style: TextStyle(color: danger)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
onTap: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => HookBuilder(
|
|
||||||
builder: (_) {
|
|
||||||
final reasonController = useTextEditingController();
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text("Report"),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"Report this event to your server administrators, who can take action like banning this server or room.",
|
|
||||||
),
|
|
||||||
|
|
||||||
SizedBox(height: 12),
|
|
||||||
FormTextInput(
|
|
||||||
required: false,
|
|
||||||
capitalize: true,
|
|
||||||
controller: reasonController,
|
|
||||||
title: "Reason for report (optional)",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
child: Text("Cancel"),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
client.reportEvent(
|
|
||||||
ReportRequest(
|
|
||||||
roomId: roomId,
|
|
||||||
eventId: message.id,
|
|
||||||
reason: reasonController.text.isEmpty
|
|
||||||
? null
|
|
||||||
: reasonController.text,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Text("Report"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.report, color: danger),
|
|
||||||
title: Text("Report", style: TextStyle(color: danger)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
final chatTheme = ChatTheme.fromThemeData(theme).copyWith(
|
|
||||||
colors: ChatColors.fromThemeData(theme).copyWith(
|
|
||||||
primary: theme.colorScheme.primaryContainer,
|
|
||||||
onPrimary: theme.colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: RoomAppbar(
|
|
||||||
isDesktop: isDesktop,
|
|
||||||
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
|
|
||||||
onOpenMemberList: (thisContext) {
|
|
||||||
memberListOpened.value = !memberListOpened.value;
|
|
||||||
Scaffold.of(thisContext).openEndDrawer();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
body: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ref
|
|
||||||
.watch(controllerProvider)
|
|
||||||
.betterWhen(
|
|
||||||
data: (controller) => Chat(
|
|
||||||
currentUserId: userId,
|
|
||||||
theme: chatTheme,
|
|
||||||
onMessageSecondaryTap:
|
|
||||||
(
|
|
||||||
context,
|
|
||||||
message, {
|
|
||||||
required index,
|
|
||||||
TapUpDetails? details,
|
|
||||||
}) => details?.globalPosition == null
|
|
||||||
? null
|
|
||||||
: context.showContextMenu(
|
|
||||||
globalPosition: details!.globalPosition,
|
|
||||||
children: getMessageOptions(message),
|
|
||||||
),
|
|
||||||
onMessageLongPress:
|
|
||||||
(
|
|
||||||
context,
|
|
||||||
message, {
|
|
||||||
required details,
|
|
||||||
required index,
|
|
||||||
}) => context.showContextMenu(
|
|
||||||
globalPosition: details.globalPosition,
|
|
||||||
children: getMessageOptions(message),
|
|
||||||
),
|
|
||||||
builders: Builders(
|
|
||||||
loadMoreBuilder: (_) => SizedBox.shrink(),
|
|
||||||
|
|
||||||
chatAnimatedListBuilder: (_, itemBuilder) =>
|
|
||||||
ChatAnimatedList(
|
|
||||||
itemBuilder: itemBuilder,
|
|
||||||
onEndReached:
|
|
||||||
ref.watch(
|
|
||||||
SelectedRoomController.provider.select(
|
|
||||||
(room) => room?.hasMore == true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
? notifier.loadOlder
|
|
||||||
: null,
|
|
||||||
onStartReached: () async {
|
|
||||||
final room = ref.watch(
|
|
||||||
SelectedRoomController.provider,
|
|
||||||
);
|
|
||||||
return room == null
|
|
||||||
? null
|
|
||||||
: await client.markRead(room);
|
|
||||||
},
|
|
||||||
bottomPadding: 72,
|
|
||||||
),
|
|
||||||
|
|
||||||
composerBuilder: (_) => ChatBox(
|
|
||||||
node: composerNode,
|
|
||||||
onSend:
|
|
||||||
(
|
|
||||||
text, {
|
|
||||||
required shouldMention,
|
|
||||||
required tags,
|
|
||||||
}) => notifier
|
|
||||||
.send(
|
|
||||||
text,
|
|
||||||
tags: tags,
|
|
||||||
relationType: relationType.value,
|
|
||||||
shouldMention: shouldMention,
|
|
||||||
relation: relatedMessage.value,
|
|
||||||
)
|
|
||||||
.onError(showError),
|
|
||||||
relationType: relationType.value,
|
|
||||||
relatedMessage: relatedMessage.value,
|
|
||||||
onDismiss: () => relatedMessage.value = null,
|
|
||||||
),
|
|
||||||
|
|
||||||
textMessageBuilder:
|
|
||||||
(
|
|
||||||
context,
|
|
||||||
message,
|
|
||||||
index, {
|
|
||||||
required bool isSentByMe,
|
|
||||||
MessageGroupStatus? groupStatus,
|
|
||||||
}) => TextMessageWrapper(
|
|
||||||
message,
|
|
||||||
content: message.text,
|
|
||||||
groupStatus: groupStatus,
|
|
||||||
onTapReply: notifier.scrollToMessage,
|
|
||||||
updateMessage: controller.updateMessage,
|
|
||||||
isSentByMe: isSentByMe,
|
|
||||||
),
|
|
||||||
|
|
||||||
imageMessageBuilder:
|
|
||||||
(
|
|
||||||
context,
|
|
||||||
message,
|
|
||||||
index, {
|
|
||||||
required bool isSentByMe,
|
|
||||||
MessageGroupStatus? groupStatus,
|
|
||||||
}) => TextMessageWrapper(
|
|
||||||
message,
|
|
||||||
content: message.text,
|
|
||||||
groupStatus: groupStatus,
|
|
||||||
onTapReply: notifier.scrollToMessage,
|
|
||||||
updateMessage: controller.updateMessage,
|
|
||||||
isSentByMe: isSentByMe,
|
|
||||||
extra: ExpandableImageMessage(
|
|
||||||
message,
|
|
||||||
index: index,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
fileMessageBuilder:
|
|
||||||
(
|
|
||||||
_,
|
|
||||||
message,
|
|
||||||
index, {
|
|
||||||
required bool isSentByMe,
|
|
||||||
MessageGroupStatus? groupStatus,
|
|
||||||
}) => MessageWrapper(
|
|
||||||
message,
|
|
||||||
InkWell(
|
|
||||||
onTap: () => showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => Dialog(
|
|
||||||
child: Text(
|
|
||||||
"TODO: Download Attachments",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: FlyerChatFileMessage(
|
|
||||||
topWidget: ReplyWidget(
|
|
||||||
message,
|
|
||||||
onTapReply: notifier.scrollToMessage,
|
|
||||||
groupStatus: groupStatus,
|
|
||||||
),
|
|
||||||
message: message,
|
|
||||||
index: index,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
groupStatus,
|
|
||||||
),
|
|
||||||
|
|
||||||
systemMessageBuilder:
|
|
||||||
(
|
|
||||||
_,
|
|
||||||
message,
|
|
||||||
index, {
|
|
||||||
required bool isSentByMe,
|
|
||||||
MessageGroupStatus? groupStatus,
|
|
||||||
}) => FlyerChatSystemMessage(
|
|
||||||
message: message,
|
|
||||||
index: index,
|
|
||||||
),
|
|
||||||
|
|
||||||
unsupportedMessageBuilder:
|
|
||||||
(
|
|
||||||
_,
|
|
||||||
message,
|
|
||||||
index, {
|
|
||||||
required bool isSentByMe,
|
|
||||||
MessageGroupStatus? groupStatus,
|
|
||||||
}) => Text(
|
|
||||||
"${message.authorId} sent ${message.metadata?["eventType"]}",
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
resolveUser: (_) async => null,
|
|
||||||
chatController: controller,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
if (memberListOpened.value == true && showMembersByDefault)
|
|
||||||
MemberList(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
endDrawer: showMembersByDefault ? null : MemberList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/wrappers/reaction_row.dart";
|
|
||||||
import "package:timeago/timeago.dart";
|
|
||||||
|
|
||||||
class MessageWrapper extends StatelessWidget {
|
|
||||||
final Message message;
|
|
||||||
final Widget child;
|
|
||||||
final MessageGroupStatus? groupStatus;
|
|
||||||
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final error = message.metadata?["error"];
|
|
||||||
|
|
||||||
return ClipRRect(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
padding: message.metadata?["flashing"] == true
|
|
||||||
? EdgeInsets.all(8)
|
|
||||||
: EdgeInsets.all(0),
|
|
||||||
color: message.metadata?["flashing"] == true
|
|
||||||
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
|
|
||||||
: Colors.transparent,
|
|
||||||
duration: Duration(milliseconds: 250),
|
|
||||||
child: Row(
|
|
||||||
spacing: 8,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
groupStatus?.isFirst != false
|
|
||||||
? MessageAvatar(message, height: 40)
|
|
||||||
: SizedBox(width: 40),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 4,
|
|
||||||
children: [
|
|
||||||
if (groupStatus?.isFirst != false)
|
|
||||||
Row(
|
|
||||||
spacing: 4,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: MessageDisplayname(
|
|
||||||
message,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (message.deliveredAt != null &&
|
|
||||||
groupStatus?.isFirst != false)
|
|
||||||
Tooltip(
|
|
||||||
message: message.deliveredAt!.toString(),
|
|
||||||
child: Text(
|
|
||||||
format(message.deliveredAt!),
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child,
|
|
||||||
if (error != null && error != "not sent")
|
|
||||||
Text(
|
|
||||||
error,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ReactionRow(message),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import "package:cross_cache/cross_cache.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_hooks/flutter_hooks.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/controllers/room_chat_controller.dart";
|
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
|
||||||
import "package:nexus/main.dart";
|
|
||||||
|
|
||||||
class ReactionRow extends ConsumerWidget {
|
|
||||||
final Message message;
|
|
||||||
const ReactionRow(this.message, {super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final clientState = ref.watch(ClientStateController.provider);
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
spacing: 4,
|
|
||||||
runSpacing: 4,
|
|
||||||
children: clientState?.homeserverUrl == null || message.reactions == null
|
|
||||||
? []
|
|
||||||
: message.reactions!
|
|
||||||
.mapTo(
|
|
||||||
(reaction, reactors) => HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final enabled = useState(true);
|
|
||||||
final selected = reactors.contains(clientState!.userId);
|
|
||||||
return Tooltip(
|
|
||||||
message: reactors.join(", "),
|
|
||||||
child: ChoiceChip(
|
|
||||||
showCheckmark: false,
|
|
||||||
selected: selected,
|
|
||||||
label: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: reaction.startsWith("mxc://")
|
|
||||||
? Image(
|
|
||||||
height: 20,
|
|
||||||
image: CachedNetworkImage(
|
|
||||||
headers: ref.headers,
|
|
||||||
Uri.parse(reaction)
|
|
||||||
.mxcToHttps(
|
|
||||||
clientState.homeserverUrl!,
|
|
||||||
)
|
|
||||||
.toString(),
|
|
||||||
ref.watch(
|
|
||||||
CrossCacheController.provider,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
reaction,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
reactors.length.toString(),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onSelected: enabled.value
|
|
||||||
? (value) async {
|
|
||||||
enabled.value = false;
|
|
||||||
try {
|
|
||||||
final roomId = ref.watch(
|
|
||||||
SelectedRoomController.provider.select(
|
|
||||||
(value) => value?.metadata?.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (roomId == null ||
|
|
||||||
clientState.userId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final controller = ref.watch(
|
|
||||||
RoomChatController.provider(
|
|
||||||
roomId,
|
|
||||||
).notifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selected) {
|
|
||||||
await controller
|
|
||||||
.removeReaction(
|
|
||||||
reaction,
|
|
||||||
message,
|
|
||||||
clientState.userId!,
|
|
||||||
)
|
|
||||||
.onError(showError);
|
|
||||||
} else {
|
|
||||||
await controller
|
|
||||||
.sendReaction(reaction, message)
|
|
||||||
.onError(showError);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
enabled.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import "package:cross_cache/cross_cache.dart";
|
|
||||||
import "package:flutter/material.dart";
|
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_link_previewer/flutter_link_previewer.dart";
|
|
||||||
import "package:flutter_linkify/flutter_linkify.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:nexus/controllers/cross_cache_controller.dart";
|
|
||||||
import "package:nexus/controllers/url_preview_controller.dart";
|
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
|
||||||
import "package:nexus/helpers/extensions/get_headers.dart";
|
|
||||||
import "package:nexus/helpers/launch_helper.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/html/html.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart";
|
|
||||||
import "package:nexus/widgets/chat_page/reply_widget.dart";
|
|
||||||
|
|
||||||
class TextMessageWrapper extends ConsumerWidget {
|
|
||||||
final Message message;
|
|
||||||
final String? content;
|
|
||||||
final MessageGroupStatus? groupStatus;
|
|
||||||
final Future<void> Function(Message oldMessage, Message newMessage)
|
|
||||||
updateMessage;
|
|
||||||
final bool isSentByMe;
|
|
||||||
final Widget? extra;
|
|
||||||
final OnTapReply onTapReply;
|
|
||||||
|
|
||||||
const TextMessageWrapper(
|
|
||||||
this.message, {
|
|
||||||
this.content,
|
|
||||||
this.onTapReply,
|
|
||||||
required this.updateMessage,
|
|
||||||
required this.groupStatus,
|
|
||||||
required this.isSentByMe,
|
|
||||||
this.extra,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final textMessage = message is TextMessage ? message as TextMessage : null;
|
|
||||||
|
|
||||||
final link = textMessage == null
|
|
||||||
? null
|
|
||||||
: RegExp(
|
|
||||||
r'''https?://[^\s"'<>]+''',
|
|
||||||
).allMatches(textMessage.text).firstOrNull?.group(0);
|
|
||||||
|
|
||||||
return MessageWrapper(
|
|
||||||
message,
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
||||||
child: Container(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSentByMe
|
|
||||||
? (message.id.startsWith("~")
|
|
||||||
? colorScheme.onPrimary
|
|
||||||
: colorScheme.primaryContainer)
|
|
||||||
: colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ReplyWidget(
|
|
||||||
message,
|
|
||||||
groupStatus: groupStatus,
|
|
||||||
onTapReply: onTapReply,
|
|
||||||
),
|
|
||||||
if (content != null)
|
|
||||||
message.metadata?["format"] == "org.matrix.custom.html"
|
|
||||||
? Html(
|
|
||||||
textStyle: message.metadata?["big"] == true
|
|
||||||
? TextStyle(fontSize: 32)
|
|
||||||
: null,
|
|
||||||
content!.replaceAllMapped(
|
|
||||||
RegExp(
|
|
||||||
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
|
|
||||||
caseSensitive: false,
|
|
||||||
dotAll: true,
|
|
||||||
),
|
|
||||||
(m) {
|
|
||||||
// If it's already an <a> tag, leave it unchanged
|
|
||||||
if (m.group(1) != null) {
|
|
||||||
return m.group(1)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, wrap the bare URL
|
|
||||||
final url = m.group(2)!;
|
|
||||||
return "<a href=\"$url\">$url</a>";
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Linkify(
|
|
||||||
text: content!,
|
|
||||||
options: LinkifyOptions(humanize: false),
|
|
||||||
onOpen: (link) => ref
|
|
||||||
.watch(LaunchHelper.provider)
|
|
||||||
.launchUrl(Uri.parse(link.url)),
|
|
||||||
linkStyle: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (textMessage?.editedAt != null)
|
|
||||||
Text("(edited)", style: theme.textTheme.labelSmall),
|
|
||||||
if (link != null)
|
|
||||||
ref
|
|
||||||
.watch(UrlPreviewController.provider(link))
|
|
||||||
.betterWhen(
|
|
||||||
loading: SizedBox.shrink,
|
|
||||||
data: (preview) => preview == null
|
|
||||||
? SizedBox.shrink()
|
|
||||||
: LinkPreview(
|
|
||||||
onTap: (url) => ref
|
|
||||||
.watch(LaunchHelper.provider)
|
|
||||||
.launchUrl(Uri.parse(url)),
|
|
||||||
imageBuilder: (url) => Image(
|
|
||||||
image: CachedNetworkImage(
|
|
||||||
url,
|
|
||||||
ref.watch(CrossCacheController.provider),
|
|
||||||
headers: ref.headers,
|
|
||||||
),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, _, _) => SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
text: link,
|
|
||||||
backgroundColor: isSentByMe
|
|
||||||
? colorScheme.inversePrimary
|
|
||||||
: colorScheme.surfaceContainerLow,
|
|
||||||
outsidePadding: EdgeInsets.only(top: 4),
|
|
||||||
insidePadding: EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 16,
|
|
||||||
),
|
|
||||||
linkPreviewData: preview,
|
|
||||||
onLinkPreviewDataFetched: (_) => null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (extra != null) extra!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
groupStatus,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
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_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:nexus/controllers/power_level_controller.dart";
|
import "package:nexus/controllers/power_level_controller.dart";
|
||||||
import "package:nexus/models/configs/power_level_config.dart";
|
import "package:nexus/models/configs/power_level_config.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/chat_page/composer/mention_overlay.dart";
|
import "package:nexus/widgets/composer/mention_overlay.dart";
|
||||||
import "package:nexus/widgets/chat_page/composer/relation_preview.dart";
|
import "package:nexus/widgets/composer/relation_preview.dart";
|
||||||
import "package:nexus/widgets/chat_page/emoji_picker_button.dart";
|
import "package:nexus/widgets/emoji_picker_button.dart";
|
||||||
|
|
||||||
class ChatBox extends HookConsumerWidget {
|
class ChatBox extends HookConsumerWidget {
|
||||||
final Message? relatedMessage;
|
final String roomId;
|
||||||
|
final Event? relatedEvent;
|
||||||
final RelationType relationType;
|
final RelationType relationType;
|
||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
final FocusNode? node;
|
final FocusNode? node;
|
||||||
|
|
@ -22,8 +24,9 @@ class ChatBox extends HookConsumerWidget {
|
||||||
required IList<Tag> tags,
|
required IList<Tag> tags,
|
||||||
})
|
})
|
||||||
onSend;
|
onSend;
|
||||||
const ChatBox({
|
const ChatBox(
|
||||||
required this.relatedMessage,
|
this.roomId, {
|
||||||
|
required this.relatedEvent,
|
||||||
required this.relationType,
|
required this.relationType,
|
||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
required this.onSend,
|
required this.onSend,
|
||||||
|
|
@ -39,10 +42,8 @@ class ChatBox extends HookConsumerWidget {
|
||||||
final shouldMention = useState(true);
|
final shouldMention = useState(true);
|
||||||
final query = useState("");
|
final query = useState("");
|
||||||
|
|
||||||
if (relationType == RelationType.edit &&
|
if (relationType == RelationType.edit && controller.value.text.isEmpty) {
|
||||||
relatedMessage is TextMessage &&
|
controller.value.text = relatedEvent?.localContent?.editSource ?? "";
|
||||||
controller.value.text.isEmpty) {
|
|
||||||
controller.value.text = relatedMessage?.metadata?["editSource"] ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void send() {
|
void send() {
|
||||||
|
|
@ -73,7 +74,7 @@ class ChatBox extends HookConsumerWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
RelationPreview(
|
RelationPreview(
|
||||||
relatedMessage,
|
relatedEvent,
|
||||||
shouldMention: shouldMention.value,
|
shouldMention: shouldMention.value,
|
||||||
toggleShouldMention: () =>
|
toggleShouldMention: () =>
|
||||||
shouldMention.value = !shouldMention.value,
|
shouldMention.value = !shouldMention.value,
|
||||||
|
|
@ -89,7 +90,10 @@ class ChatBox extends HookConsumerWidget {
|
||||||
children:
|
children:
|
||||||
ref.watch(
|
ref.watch(
|
||||||
PowerLevelController.provider(
|
PowerLevelController.provider(
|
||||||
PowerLevelConfig(eventType: "m.room.message"),
|
PowerLevelConfig(
|
||||||
|
eventType: EventType.message,
|
||||||
|
roomId: roomId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
? [
|
? [
|
||||||
|
|
@ -126,6 +130,7 @@ class ChatBox extends HookConsumerWidget {
|
||||||
child: FlutterTagger(
|
child: FlutterTagger(
|
||||||
triggerStrategy: TriggerStrategy.eager,
|
triggerStrategy: TriggerStrategy.eager,
|
||||||
overlay: MentionOverlay(
|
overlay: MentionOverlay(
|
||||||
|
roomId,
|
||||||
query: query.value,
|
query: query.value,
|
||||||
triggerCharacter: triggerCharacter.value,
|
triggerCharacter: triggerCharacter.value,
|
||||||
addTag: ({required id, required name}) {
|
addTag: ({required id, required name}) {
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
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/members_by_type_controller.dart";
|
import "package:nexus/controllers/members_by_status_controller.dart";
|
||||||
import "package:nexus/controllers/rooms_controller.dart";
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
import "package:nexus/controllers/via_controller.dart";
|
import "package:nexus/controllers/via_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/models/configs/members_by_status_config.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/membership_status.dart";
|
import "package:nexus/models/membership_status.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";
|
||||||
|
|
@ -11,8 +14,10 @@ import "package:nexus/widgets/loading.dart";
|
||||||
class MentionOverlay extends ConsumerWidget {
|
class MentionOverlay extends ConsumerWidget {
|
||||||
final String? triggerCharacter;
|
final String? triggerCharacter;
|
||||||
final String query;
|
final String query;
|
||||||
|
final String roomId;
|
||||||
final void Function({required String id, required String name}) addTag;
|
final void Function({required String id, required String name}) addTag;
|
||||||
const MentionOverlay({
|
const MentionOverlay(
|
||||||
|
this.roomId, {
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.addTag,
|
required this.addTag,
|
||||||
required this.triggerCharacter,
|
required this.triggerCharacter,
|
||||||
|
|
@ -34,7 +39,12 @@ class MentionOverlay extends ConsumerWidget {
|
||||||
"@" =>
|
"@" =>
|
||||||
ref
|
ref
|
||||||
.watch(
|
.watch(
|
||||||
MembersByTypeController.provider(MembershipStatus.join),
|
MembersByStatusController.provider(
|
||||||
|
MembersByStatusConfig(
|
||||||
|
roomId: roomId,
|
||||||
|
status: MembershipStatus.join,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (members) => ListView(
|
data: (members) => ListView(
|
||||||
|
|
@ -43,33 +53,49 @@ class MentionOverlay extends ConsumerWidget {
|
||||||
? members
|
? members
|
||||||
: members.where(
|
: members.where(
|
||||||
(member) =>
|
(member) =>
|
||||||
member.userId.toLowerCase().contains(
|
member.stateKey
|
||||||
|
?.toLowerCase()
|
||||||
|
.contains(
|
||||||
query.toLowerCase(),
|
query.toLowerCase(),
|
||||||
) ==
|
) ==
|
||||||
true ||
|
true ||
|
||||||
member.displayName
|
switch (member.content) {
|
||||||
.toLowerCase()
|
MembershipContent(
|
||||||
|
:final displayName,
|
||||||
|
) =>
|
||||||
|
displayName
|
||||||
|
?.toLowerCase()
|
||||||
.contains(
|
.contains(
|
||||||
query.toLowerCase(),
|
query.toLowerCase(),
|
||||||
) ==
|
) ==
|
||||||
true,
|
true,
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
.map(
|
.map(
|
||||||
(member) => ListTile(
|
(member) => switch (member.content) {
|
||||||
|
MembershipContent(
|
||||||
|
:final displayName,
|
||||||
|
:final avatarUrl,
|
||||||
|
) =>
|
||||||
|
ListTile(
|
||||||
leading: AvatarOrHash(
|
leading: AvatarOrHash(
|
||||||
member.avatarUrl,
|
avatarUrl,
|
||||||
member.displayName,
|
displayName ??
|
||||||
|
member.stateKey!.localpart,
|
||||||
),
|
),
|
||||||
title: Text(member.displayName),
|
title: Text(
|
||||||
subtitle: Text(member.userId),
|
displayName ??
|
||||||
|
member.stateKey!.localpart,
|
||||||
|
),
|
||||||
|
subtitle: Text(member.stateKey!),
|
||||||
onTap: () => addTag(
|
onTap: () => addTag(
|
||||||
id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})",
|
id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})",
|
||||||
name: member.userId
|
name: member.stateKey!.localpart,
|
||||||
.substring(1)
|
|
||||||
.split(":")
|
|
||||||
.first,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_ => SizedBox.shrink(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.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:nexus/models/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
|
import "package:nexus/widgets/event_preview.dart";
|
||||||
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
|
|
||||||
|
|
||||||
class RelationPreview extends ConsumerWidget {
|
class RelationPreview extends ConsumerWidget {
|
||||||
final Message? relatedMessage;
|
final Event? relatedEvent;
|
||||||
final RelationType relationType;
|
final RelationType relationType;
|
||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
final bool shouldMention;
|
final bool shouldMention;
|
||||||
final VoidCallback toggleShouldMention;
|
final VoidCallback toggleShouldMention;
|
||||||
|
|
||||||
const RelationPreview(
|
const RelationPreview(
|
||||||
this.relatedMessage, {
|
this.relatedEvent, {
|
||||||
required this.relationType,
|
required this.relationType,
|
||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
required this.shouldMention,
|
required this.shouldMention,
|
||||||
|
|
@ -23,12 +22,12 @@ class RelationPreview extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (relatedMessage == null) return SizedBox.shrink();
|
if (relatedEvent == null) return SizedBox.shrink();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -38,32 +37,10 @@ class RelationPreview extends ConsumerWidget {
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
|
|
||||||
MessageAvatar(relatedMessage!),
|
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Padding(
|
||||||
spacing: 8,
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
children: [
|
child: EventPreview(relatedEvent!),
|
||||||
Flexible(
|
|
||||||
child: MessageDisplayname(
|
|
||||||
relatedMessage!,
|
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
relatedMessage?.metadata?["body"] ??
|
|
||||||
relatedMessage?.metadata?["eventType"] ??
|
|
||||||
"",
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
softWrap: false,
|
|
||||||
style: theme.textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
36
lib/widgets/event_preview.dart
Normal file
36
lib/widgets/event_preview.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||||
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
|
|
||||||
|
class EventPreview extends StatelessWidget {
|
||||||
|
final Event event;
|
||||||
|
const EventPreview(this.event, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => IgnorePointer(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
if (event.content is MessageContent) MessageAvatar(event),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 2,
|
||||||
|
children: [
|
||||||
|
if (event.content is MessageContent) MessageDisplayname(event),
|
||||||
|
EventRenderer(event, textOnly: true, maxLines: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
29
lib/widgets/file_card.dart
Normal file
29
lib/widgets/file_card.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:nexus/helpers/extensions/size_to_string.dart";
|
||||||
|
import "package:nexus/models/info/file.dart";
|
||||||
|
|
||||||
|
class FileCard extends StatelessWidget {
|
||||||
|
final Uri uri;
|
||||||
|
final FileInfo? info;
|
||||||
|
final String? filename;
|
||||||
|
const FileCard(this.uri, this.info, {this.filename, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.file_copy),
|
||||||
|
title: Text(
|
||||||
|
filename ?? "file",
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: info?.size == null ? null : Text(info!.size!.sizeAsString),
|
||||||
|
// TODO: Downloading files
|
||||||
|
trailing: IconButton(onPressed: null, icon: Icon(Icons.download)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
20
lib/widgets/flash_wrapper.dart
Normal file
20
lib/widgets/flash_wrapper.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class FlashWrapper extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final bool isFlashing;
|
||||||
|
const FlashWrapper(this.child, {this.isFlashing = false, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0),
|
||||||
|
color: isFlashing
|
||||||
|
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
|
||||||
|
: Colors.transparent,
|
||||||
|
duration: Duration(milliseconds: 250),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,20 +9,22 @@ import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
||||||
import "package:nexus/helpers/extensions/mxc_to_https.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/widgets/chat_page/expandable_image.dart";
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
import "package:nexus/widgets/chat_page/html/mention_chip.dart";
|
import "package:nexus/widgets/html/mention_chip.dart";
|
||||||
import "package:nexus/widgets/chat_page/html/spoiler_text.dart";
|
import "package:nexus/widgets/html/spoiler_text.dart";
|
||||||
import "package:nexus/widgets/chat_page/html/code_block.dart";
|
import "package:nexus/widgets/html/code_block.dart";
|
||||||
import "package:nexus/widgets/chat_page/html/quoted.dart";
|
import "package:nexus/widgets/html/quoted.dart";
|
||||||
|
|
||||||
class Html extends ConsumerWidget {
|
class Html extends ConsumerWidget {
|
||||||
final String html;
|
final String html;
|
||||||
|
final String? roomId;
|
||||||
final TextStyle? textStyle;
|
final TextStyle? textStyle;
|
||||||
const Html(this.html, {this.textStyle, super.key});
|
const Html(this.html, {this.roomId, this.textStyle, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
|
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
|
||||||
html,
|
html,
|
||||||
|
buildAsync: false,
|
||||||
textStyle: textStyle,
|
textStyle: textStyle,
|
||||||
customWidgetBuilder: (element) {
|
customWidgetBuilder: (element) {
|
||||||
if (element.attributes.keys.contains("data-mx-profile-fallback")) {
|
if (element.attributes.keys.contains("data-mx-profile-fallback")) {
|
||||||
|
|
@ -58,13 +60,15 @@ class Html extends ConsumerWidget {
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
||||||
"blockquote" => Quoted(Html(element.innerHtml)),
|
"blockquote" => Quoted(
|
||||||
|
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
|
||||||
|
),
|
||||||
|
|
||||||
"a" =>
|
"a" =>
|
||||||
element.attributes["href"]?.mention == null
|
element.attributes["href"]?.mention == null
|
||||||
? null
|
? null
|
||||||
: InlineCustomWidget(
|
: InlineCustomWidget(
|
||||||
child: MentionChip(element.attributes["href"]!),
|
child: MentionChip(element.attributes["href"]!, roomId),
|
||||||
),
|
),
|
||||||
|
|
||||||
"img" =>
|
"img" =>
|
||||||
53
lib/widgets/html/mention_chip.dart
Normal file
53
lib/widgets/html/mention_chip.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/user_controller.dart";
|
||||||
|
import "package:nexus/helpers/extensions/link_to_mention.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/models/configs/user_config.dart";
|
||||||
|
|
||||||
|
class MentionChip extends ConsumerWidget {
|
||||||
|
final String? roomId;
|
||||||
|
final String content;
|
||||||
|
const MentionChip(this.content, this.roomId, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final mention = content.mention;
|
||||||
|
final membership = mention?.startsWith("@") == true
|
||||||
|
? ref
|
||||||
|
.watch(
|
||||||
|
UserController.provider(
|
||||||
|
UserConfig(roomId: roomId, userId: mention!),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.whenOrNull(data: (data) => data)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return mention == null
|
||||||
|
? SizedBox.shrink()
|
||||||
|
: InkWell(
|
||||||
|
onTapUp: (details) {
|
||||||
|
if (membership != null) {
|
||||||
|
context.showUserPopover(
|
||||||
|
membership,
|
||||||
|
mention,
|
||||||
|
globalPosition: details.globalPosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Chip(
|
||||||
|
label: Text(
|
||||||
|
(membership == null ? null : "@${membership.displayName}") ??
|
||||||
|
mention,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,36 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/author_controller.dart";
|
import "package:nexus/controllers/author_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
|
||||||
class MessageAvatar extends ConsumerWidget {
|
class MessageAvatar extends ConsumerWidget {
|
||||||
final Message message;
|
final Event event;
|
||||||
final double height;
|
final double height;
|
||||||
const MessageAvatar(this.message, {this.height = 16, super.key});
|
const MessageAvatar(this.event, {this.height = 24, super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||||
.watch(AuthorController.provider(message))
|
.watch(AuthorController.provider(event))
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (membership) => InkWell(
|
data: (membership) => InkWell(
|
||||||
onTapUp: (details) => context.showUserPopover(
|
onTapUp: (details) {
|
||||||
|
context.showUserPopover(
|
||||||
membership,
|
membership,
|
||||||
|
event.sender,
|
||||||
globalPosition: details.globalPosition,
|
globalPosition: details.globalPosition,
|
||||||
),
|
);
|
||||||
|
},
|
||||||
child: AvatarOrHash(
|
child: AvatarOrHash(
|
||||||
membership.avatarUrl,
|
membership.avatarUrl,
|
||||||
membership.displayName,
|
membership.displayName ?? event.sender.localpart,
|
||||||
height: height,
|
height: height,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
loading: () =>
|
loading: () =>
|
||||||
AvatarOrHash(null, message.authorId.substring(1), height: height),
|
AvatarOrHash(null, event.sender.localpart, height: height),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_chat_core/flutter_chat_core.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/author_controller.dart";
|
import "package:nexus/controllers/author_controller.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class MessageDisplayname extends ConsumerWidget {
|
class MessageDisplayname extends ConsumerWidget {
|
||||||
final Message message;
|
final Event event;
|
||||||
final TextStyle? style;
|
final TextStyle? style;
|
||||||
final bool clickable;
|
final bool clickable;
|
||||||
const MessageDisplayname(
|
const MessageDisplayname(
|
||||||
this.message, {
|
this.event, {
|
||||||
this.clickable = true,
|
this.clickable = true,
|
||||||
this.style,
|
this.style,
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -18,18 +20,25 @@ class MessageDisplayname extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||||
.watch(AuthorController.provider(message))
|
.watch(AuthorController.provider(event))
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (membership) => InkWell(
|
data: (membership) => InkWell(
|
||||||
onTapUp: clickable
|
onTapUp: clickable
|
||||||
? (details) => context.showUserPopover(
|
? (details) => context.showUserPopover(
|
||||||
membership,
|
membership,
|
||||||
|
event.sender,
|
||||||
globalPosition: details.globalPosition,
|
globalPosition: details.globalPosition,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
child: Text(
|
child: Text(
|
||||||
"${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}",
|
"${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}",
|
||||||
style: style,
|
style:
|
||||||
|
style ??
|
||||||
|
TextStyle(
|
||||||
|
color: event.sender.colorHash,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
119
lib/widgets/member_list.dart
Normal file
119
lib/widgets/member_list.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/members_by_status_controller.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
|
import "package:nexus/models/configs/members_by_status_config.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
||||||
|
class MemberList extends HookConsumerWidget {
|
||||||
|
final String roomId;
|
||||||
|
const MemberList(this.roomId, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final status = useState(MembershipStatus.join);
|
||||||
|
final membersProvider = ref.watch(
|
||||||
|
MembersByStatusController.provider(
|
||||||
|
MembersByStatusConfig(roomId: roomId, status: status.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Drawer(
|
||||||
|
shape: Border(),
|
||||||
|
child: Column(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
AppBar(
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
leading: Icon(Icons.people),
|
||||||
|
title: Text("Members"),
|
||||||
|
actionsPadding: EdgeInsets.only(right: 4),
|
||||||
|
actions: [
|
||||||
|
if (Scaffold.of(context).hasEndDrawer)
|
||||||
|
IconButton(
|
||||||
|
onPressed: Scaffold.of(context).closeEndDrawer,
|
||||||
|
icon: Icon(Icons.close),
|
||||||
|
tooltip: "Close member list",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
label: Text("Joined"),
|
||||||
|
onSelected: (value) => status.value = MembershipStatus.join,
|
||||||
|
selected: status.value == MembershipStatus.join,
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: Text("Invited"),
|
||||||
|
onSelected: (value) => status.value = MembershipStatus.invite,
|
||||||
|
selected: status.value == MembershipStatus.invite,
|
||||||
|
),
|
||||||
|
FilterChip(
|
||||||
|
label: Text("Banned"),
|
||||||
|
onSelected: (value) => status.value = MembershipStatus.ban,
|
||||||
|
selected: status.value == MembershipStatus.ban,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
switch (membersProvider) {
|
||||||
|
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
),
|
||||||
|
AsyncData(:final value) || AsyncLoading(:final value?) => Expanded(
|
||||||
|
child: ListView(
|
||||||
|
children: value
|
||||||
|
.map(
|
||||||
|
(member) => switch (member.content) {
|
||||||
|
MembershipContent(
|
||||||
|
:final avatarUrl,
|
||||||
|
:final displayName,
|
||||||
|
) =>
|
||||||
|
InkWell(
|
||||||
|
onTapUp: (details) => context.showUserPopover(
|
||||||
|
member.content as MembershipContent,
|
||||||
|
member.stateKey!,
|
||||||
|
globalPosition: details.globalPosition,
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: AvatarOrHash(
|
||||||
|
avatarUrl,
|
||||||
|
displayName ?? member.sender.localpart,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
displayName ?? member.stateKey!.localpart,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: member.stateKey!.colorHash,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
member.stateKey!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => SizedBox.shrink(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AsyncLoading _ => Loading(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
lib/widgets/players/audio.dart
Normal file
104
lib/widgets/players/audio.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:media_kit/media_kit.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
|
import "package:nexus/models/info/audio.dart";
|
||||||
|
|
||||||
|
class AudioPlayer extends HookConsumerWidget {
|
||||||
|
final Uri url;
|
||||||
|
final AudioInfo? info;
|
||||||
|
|
||||||
|
const AudioPlayer(this.url, this.info, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final player = useMemoized(
|
||||||
|
() => Player(
|
||||||
|
configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final playing = useState(false);
|
||||||
|
final position = useState(Duration.zero);
|
||||||
|
final duration = useState(Duration.zero);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
await player.open(
|
||||||
|
Media(url.toString(), httpHeaders: ref.headers),
|
||||||
|
play: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
player.stream.playing.listen((value) {
|
||||||
|
playing.value = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
player.stream.position.listen((value) {
|
||||||
|
position.value = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
player.stream.duration.listen((value) {
|
||||||
|
duration.value = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return player.dispose;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
String format(Duration duration) {
|
||||||
|
final minutes = duration.inMinutes
|
||||||
|
.remainder(60)
|
||||||
|
.toString()
|
||||||
|
.padLeft(2, "0");
|
||||||
|
final seconds = duration.inSeconds
|
||||||
|
.remainder(60)
|
||||||
|
.toString()
|
||||||
|
.padLeft(2, "0");
|
||||||
|
|
||||||
|
return "$minutes:$seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 60,
|
||||||
|
child: Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsetsGeometry.only(left: 8, right: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: player.playOrPause,
|
||||||
|
icon: Icon(
|
||||||
|
playing.value ? Icons.pause_circle : Icons.play_circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
format(position.value),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
min: 0,
|
||||||
|
max: duration.value.inMilliseconds <= 0
|
||||||
|
? 1
|
||||||
|
: duration.value.inMilliseconds.toDouble(),
|
||||||
|
value: position.value.inMilliseconds.toDouble(),
|
||||||
|
onChanged: (value) =>
|
||||||
|
player.seek(Duration(milliseconds: value.toInt())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
format(duration.value),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/widgets/players/video.dart
Normal file
38
lib/widgets/players/video.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/models/info/video.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:media_kit/media_kit.dart";
|
||||||
|
import "package:media_kit_video/media_kit_video.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
|
|
||||||
|
class VideoPlayer extends HookConsumerWidget {
|
||||||
|
final VideoInfo? info;
|
||||||
|
final Uri url;
|
||||||
|
const VideoPlayer(this.url, this.info, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final player = useMemoized(
|
||||||
|
() => Player(
|
||||||
|
configuration: PlayerConfiguration(bufferSize: 128 * 1024 * 1024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final controller = useMemoized(() => VideoController(player));
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
scheduleMicrotask(
|
||||||
|
() => player.open(
|
||||||
|
Media(url.toString(), httpHeaders: ref.headers),
|
||||||
|
play: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return player.dispose;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return SizedBox(height: 300, child: Video(controller: controller));
|
||||||
|
}
|
||||||
|
}
|
||||||
120
lib/widgets/reaction_row.dart
Normal file
120
lib/widgets/reaction_row.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import "package:cross_cache/cross_cache.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.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/controllers/reactions_controller.dart";
|
||||||
|
import "package:nexus/controllers/room_chat_controller.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
|
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||||
|
import "package:nexus/models/configs/reactions_config.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
|
||||||
|
class ReactionRow extends ConsumerWidget {
|
||||||
|
final Event event;
|
||||||
|
const ReactionRow(this.event, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final clientState = ref.watch(ClientStateController.provider);
|
||||||
|
|
||||||
|
return switch (ref.watch(
|
||||||
|
ReactionsController.provider(
|
||||||
|
ReactionsConfig(roomId: event.roomId, eventRowId: event.rowId),
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
AsyncData(value: final IMap<String, IList<String>>? reactors) ||
|
||||||
|
AsyncLoading(value: final reactors) => Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: event.reactions
|
||||||
|
.where((_, value) => value != 0)
|
||||||
|
.mapTo(
|
||||||
|
(reaction, count) => HookBuilder(
|
||||||
|
builder: (context) {
|
||||||
|
final enabled = useState(true);
|
||||||
|
|
||||||
|
final selected =
|
||||||
|
reactors?[reaction]?.contains(clientState!.userId) ??
|
||||||
|
false;
|
||||||
|
return Tooltip(
|
||||||
|
message: reactors?[reaction]?.join(", ") ?? "",
|
||||||
|
child: ChoiceChip(
|
||||||
|
showCheckmark: false,
|
||||||
|
selected: selected,
|
||||||
|
label: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: reaction.startsWith("mxc://")
|
||||||
|
? Image(
|
||||||
|
height: 20,
|
||||||
|
image: CachedNetworkImage(
|
||||||
|
headers: ref.headers,
|
||||||
|
Uri.parse(reaction)
|
||||||
|
.mxcToHttps(
|
||||||
|
clientState!.homeserverUrl!,
|
||||||
|
)
|
||||||
|
.toString(),
|
||||||
|
ref.watch(CrossCacheController.provider),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
reaction,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onSelected: enabled.value
|
||||||
|
? (value) async {
|
||||||
|
enabled.value = false;
|
||||||
|
try {
|
||||||
|
final controller = ref.watch(
|
||||||
|
RoomChatController.provider(
|
||||||
|
event.roomId,
|
||||||
|
).notifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
await controller
|
||||||
|
.removeReaction(
|
||||||
|
reaction,
|
||||||
|
event,
|
||||||
|
clientState!.userId!,
|
||||||
|
)
|
||||||
|
.onError(showError);
|
||||||
|
} else {
|
||||||
|
await controller
|
||||||
|
.sendReaction(reaction, event)
|
||||||
|
.onError(showError);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
enabled.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
451
lib/widgets/renderers/event.dart
Normal file
451
lib/widgets/renderers/event.dart
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:cross_cache/cross_cache.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_blurhash/flutter_blurhash.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:linkify/linkify.dart";
|
||||||
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
|
import "package:nexus/controllers/cross_cache_controller.dart";
|
||||||
|
import "package:nexus/controllers/event_controller.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_headers.dart";
|
||||||
|
import "package:nexus/helpers/extensions/mxc_to_https.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_context_menu.dart";
|
||||||
|
import "package:nexus/helpers/launch_helper.dart";
|
||||||
|
import "package:nexus/models/content/avatar.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/encrypted.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/requests/get_event_request.dart";
|
||||||
|
import "package:nexus/widgets/event_preview.dart";
|
||||||
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
|
import "package:nexus/widgets/html/html.dart";
|
||||||
|
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||||
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
|
import "package:nexus/widgets/url_preview.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
import "package:nexus/widgets/players/video.dart";
|
||||||
|
import "package:nexus/widgets/players/audio.dart";
|
||||||
|
import "package:nexus/widgets/reaction_row.dart";
|
||||||
|
import "package:nexus/widgets/renderers/membership.dart";
|
||||||
|
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
|
import "package:nexus/widgets/file_card.dart";
|
||||||
|
import "package:timeago/timeago.dart";
|
||||||
|
import "package:flutter_linkify/flutter_linkify.dart";
|
||||||
|
|
||||||
|
class EventRenderer extends ConsumerWidget {
|
||||||
|
final Event event;
|
||||||
|
final bool textOnly;
|
||||||
|
final bool isGrouped;
|
||||||
|
final int? maxLines;
|
||||||
|
final VoidCallback? onTapReply;
|
||||||
|
final IList<PopupMenuEntry> Function(Event event)? getEventOptions;
|
||||||
|
const EventRenderer(
|
||||||
|
this.event, {
|
||||||
|
this.onTapReply,
|
||||||
|
this.textOnly = false,
|
||||||
|
this.isGrouped = false,
|
||||||
|
this.maxLines,
|
||||||
|
this.getEventOptions,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final errorStyle = TextStyle(color: colorScheme.error);
|
||||||
|
|
||||||
|
final timestamp = Tooltip(
|
||||||
|
message: event.timestamp.toString(),
|
||||||
|
child: Text(
|
||||||
|
format(event.timestamp),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final contextMenuCallback = getEventOptions == null
|
||||||
|
? null
|
||||||
|
: (details) => context.showContextMenu(
|
||||||
|
globalPosition: details.globalPosition,
|
||||||
|
children: getEventOptions!(event).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final textStyle = TextStyle(
|
||||||
|
fontSize: event.localContent?.bigEmoji == true ? 32 : null,
|
||||||
|
fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final child = event.redactedBy != null || event.relationType == "m.replace"
|
||||||
|
? null
|
||||||
|
: switch (event.content) {
|
||||||
|
Content(:final parseError?) => SelectableText(
|
||||||
|
"An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}",
|
||||||
|
style: errorStyle,
|
||||||
|
),
|
||||||
|
MessageContent() || EncryptedContent() => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (!textOnly)
|
||||||
|
if (isGrouped)
|
||||||
|
SizedBox(width: 40)
|
||||||
|
else
|
||||||
|
MessageAvatar(event, height: 40),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
spacing: 4,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!isGrouped && !textOnly)
|
||||||
|
Row(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
Flexible(child: MessageDisplayname(event)),
|
||||||
|
Flexible(flex: 0, child: timestamp),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Card(
|
||||||
|
margin: textOnly
|
||||||
|
? EdgeInsets.zero
|
||||||
|
: EdgeInsets.only(bottom: 4),
|
||||||
|
color: textOnly
|
||||||
|
? Colors.transparent
|
||||||
|
: ref.watch(
|
||||||
|
ClientStateController.provider.select(
|
||||||
|
(value) => value?.userId,
|
||||||
|
),
|
||||||
|
) ==
|
||||||
|
event.sender
|
||||||
|
? (event.eventId.startsWith("~")
|
||||||
|
? colorScheme.onPrimary
|
||||||
|
: colorScheme.primaryContainer)
|
||||||
|
: colorScheme.surfaceContainer,
|
||||||
|
elevation: textOnly ? 0 : null,
|
||||||
|
|
||||||
|
child: Padding(
|
||||||
|
padding: textOnly
|
||||||
|
? EdgeInsets.zero
|
||||||
|
: EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!textOnly && event.replyTo != null)
|
||||||
|
Card(
|
||||||
|
margin: EdgeInsets.only(bottom: 8),
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTapReply,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsetsGeometry.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
child: switch (ref.watch(
|
||||||
|
EventController.provider(
|
||||||
|
GetEventRequest(
|
||||||
|
roomId: event.roomId,
|
||||||
|
eventId: event.replyTo!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
AsyncData(:final value?) ||
|
||||||
|
AsyncLoading(
|
||||||
|
:final value?,
|
||||||
|
) => EventPreview(value),
|
||||||
|
AsyncError _ => Text(
|
||||||
|
"An error occurred while fetching the reply",
|
||||||
|
style: errorStyle,
|
||||||
|
),
|
||||||
|
_ => Text("Fetching event..."),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
switch (event.content) {
|
||||||
|
EncryptedContent() => Text(
|
||||||
|
"Unable to decrypt event",
|
||||||
|
style: errorStyle,
|
||||||
|
),
|
||||||
|
// TODO: Handle locations
|
||||||
|
// LocationMessageContent(:final body , :final geoUri) =>
|
||||||
|
TextMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) ||
|
||||||
|
NoticeMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) ||
|
||||||
|
EmoteMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) ||
|
||||||
|
ImageMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) ||
|
||||||
|
VideoMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) ||
|
||||||
|
AudioMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) ||
|
||||||
|
FileMessageContent(
|
||||||
|
:final body,
|
||||||
|
:final formattedBody,
|
||||||
|
:final format,
|
||||||
|
) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
format == MessageFormat.html && !textOnly
|
||||||
|
? Html(
|
||||||
|
roomId: event.roomId,
|
||||||
|
textStyle: textStyle,
|
||||||
|
formattedBody!.replaceAllMapped(
|
||||||
|
RegExp(
|
||||||
|
r"(<a\b[^>]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
|
||||||
|
caseSensitive: false,
|
||||||
|
dotAll: true,
|
||||||
|
),
|
||||||
|
(m) {
|
||||||
|
// If it's already an <a> tag, leave it unchanged
|
||||||
|
if (m.group(1) != null) {
|
||||||
|
return m.group(1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wrap the bare URL
|
||||||
|
final url = m.group(2)!;
|
||||||
|
return "<a href=\"$url\">$url</a>";
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Linkify(
|
||||||
|
style: textStyle,
|
||||||
|
text: body,
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: maxLines == null
|
||||||
|
? null
|
||||||
|
: TextOverflow.ellipsis,
|
||||||
|
options: LinkifyOptions(
|
||||||
|
humanize: false,
|
||||||
|
),
|
||||||
|
onOpen: (link) => ref
|
||||||
|
.watch(LaunchHelper.provider)
|
||||||
|
.launchUrl(Uri.parse(link.url)),
|
||||||
|
linkStyle: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (!textOnly) ...[
|
||||||
|
if (event.content
|
||||||
|
case ImageMessageContent(
|
||||||
|
:final url,
|
||||||
|
) ||
|
||||||
|
FileMessageContent(:final url) ||
|
||||||
|
VideoMessageContent(:final url) ||
|
||||||
|
AudioMessageContent(:final url))
|
||||||
|
switch (url?.mxcToHttps(
|
||||||
|
ref.watch(
|
||||||
|
ClientStateController.provider
|
||||||
|
.select(
|
||||||
|
(value) =>
|
||||||
|
value!.homeserverUrl!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
final url? => ConstrainedBox(
|
||||||
|
constraints: BoxConstraints.loose(
|
||||||
|
Size.square(500),
|
||||||
|
),
|
||||||
|
child: switch (event.content) {
|
||||||
|
VideoMessageContent(
|
||||||
|
:final info,
|
||||||
|
) =>
|
||||||
|
VideoPlayer(url, info),
|
||||||
|
AudioMessageContent(
|
||||||
|
:final info,
|
||||||
|
) =>
|
||||||
|
AudioPlayer(url, info),
|
||||||
|
FileMessageContent(
|
||||||
|
:final info,
|
||||||
|
:final filename,
|
||||||
|
) =>
|
||||||
|
FileCard(
|
||||||
|
url,
|
||||||
|
info,
|
||||||
|
filename: filename,
|
||||||
|
),
|
||||||
|
ImageMessageContent(:final info) => ExpandableImage(
|
||||||
|
url.toString(),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.all(
|
||||||
|
Radius.circular(8),
|
||||||
|
),
|
||||||
|
child: Image(
|
||||||
|
image: CachedNetworkImage(
|
||||||
|
url.toString(),
|
||||||
|
ref.watch(
|
||||||
|
CrossCacheController
|
||||||
|
.provider,
|
||||||
|
),
|
||||||
|
headers: ref.headers,
|
||||||
|
),
|
||||||
|
width: info?.width,
|
||||||
|
loadingBuilder:
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
child,
|
||||||
|
loadingProgress,
|
||||||
|
) => loadingProgress == null
|
||||||
|
? child
|
||||||
|
: switch (info?.blurHash) {
|
||||||
|
final blurHash? => SizedBox(
|
||||||
|
width:
|
||||||
|
info?.width ??
|
||||||
|
info?.height ??
|
||||||
|
200,
|
||||||
|
height:
|
||||||
|
info?.height ??
|
||||||
|
info?.width ??
|
||||||
|
200,
|
||||||
|
child: BlurHash(
|
||||||
|
hash: blurHash,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => Loading(),
|
||||||
|
},
|
||||||
|
errorBuilder:
|
||||||
|
(
|
||||||
|
context,
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
) => Center(
|
||||||
|
child: Text(
|
||||||
|
"Image Failed to Load",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => SizedBox.shrink(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ => Text(
|
||||||
|
"Nexus currently cannot handle encrypted media",
|
||||||
|
style: errorStyle,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
if (event.lastEditRowId != 0)
|
||||||
|
Text(
|
||||||
|
"(edited)",
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
|
||||||
|
if (linkify(body).firstWhereOrNull(
|
||||||
|
(element) => element is UrlElement,
|
||||||
|
)
|
||||||
|
case final UrlElement link?)
|
||||||
|
UrlPreview(link.url),
|
||||||
|
|
||||||
|
SizedBox(height: 4),
|
||||||
|
ReactionRow(event),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MessageContent(:final body) => Row(
|
||||||
|
spacing: 8,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Unknown message type:",
|
||||||
|
style: errorStyle,
|
||||||
|
),
|
||||||
|
Text(body),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_ => throw Exception("This is impossible"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MembershipContent content =>
|
||||||
|
event.previousContent is MembershipContent &&
|
||||||
|
(event.previousContent as MembershipContent).status ==
|
||||||
|
content.status
|
||||||
|
? null
|
||||||
|
: MembershipRenderer(event),
|
||||||
|
AvatarContent() => GenericEventRenderer(Icons.numbers, [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Icon(Icons.numbers),
|
||||||
|
),
|
||||||
|
Flexible(child: MessageDisplayname(event)),
|
||||||
|
Expanded(child: Text("changed the room avatar")),
|
||||||
|
]),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (child != null) ...[
|
||||||
|
if (textOnly)
|
||||||
|
child
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onSecondaryTapUp: contextMenuCallback,
|
||||||
|
onLongPressStart: contextMenuCallback,
|
||||||
|
child: Padding(
|
||||||
|
padding: isGrouped ? EdgeInsets.zero : EdgeInsets.only(top: 8),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (event.content is! MessageContent)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsetsGeometry.only(left: 12),
|
||||||
|
child: ReactionRow(event),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (event.sendError != null && event.sendError != "not sent")
|
||||||
|
Text(
|
||||||
|
event.sendError!,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (textOnly)
|
||||||
|
Text("Unknown event type", style: errorStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
lib/widgets/renderers/generic_event.dart
Normal file
22
lib/widgets/renderers/generic_event.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
class GenericEventRenderer extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final List<Widget> children;
|
||||||
|
const GenericEventRenderer(this.icon, this.children, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Icon(Icons.people),
|
||||||
|
),
|
||||||
|
Expanded(child: Wrap(spacing: 4, children: children)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
57
lib/widgets/renderers/membership.dart
Normal file
57
lib/widgets/renderers/membership.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
|
import "package:nexus/helpers/extensions/string_to_color.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
|
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
|
|
||||||
|
class MembershipRenderer extends StatelessWidget {
|
||||||
|
final Event event;
|
||||||
|
const MembershipRenderer(this.event, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
assert(
|
||||||
|
event.content is MembershipContent,
|
||||||
|
"Make sure to only pass membership events to MembershipRenderer",
|
||||||
|
);
|
||||||
|
|
||||||
|
return switch (event.content) {
|
||||||
|
MembershipContent content => GenericEventRenderer(Icons.people, [
|
||||||
|
InkWell(
|
||||||
|
onTapUp: (details) => context.showUserPopover(
|
||||||
|
content,
|
||||||
|
event.stateKey!,
|
||||||
|
globalPosition: details.globalPosition,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
content.displayName ?? event.stateKey!.localpart,
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
color: event.sender.colorHash,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
"${switch (content.status) {
|
||||||
|
MembershipStatus.invite => "was invited to",
|
||||||
|
MembershipStatus.join => "joined",
|
||||||
|
MembershipStatus.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
||||||
|
MembershipStatus.ban => "was banned from",
|
||||||
|
MembershipStatus.knock => "asked to join",
|
||||||
|
}} the room${event.sender == event.stateKey ? "" : " by "}",
|
||||||
|
),
|
||||||
|
if (event.sender != event.stateKey) MessageDisplayname(event),
|
||||||
|
if (content.reason != null) Text("for \"${content.reason}\""),
|
||||||
|
]),
|
||||||
|
_ => SizedBox.shrink(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
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:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/selected_room_controller.dart";
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
|
import "package:nexus/helpers/extensions/mxc_to_https.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/expandable_image.dart";
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
import "package:nexus/widgets/chat_page/room_menu.dart";
|
import "package:nexus/widgets/room_menu.dart";
|
||||||
|
|
||||||
class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
|
class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
final bool isDesktop;
|
final bool isDesktop;
|
||||||
final void Function(BuildContext context)? onOpenMemberList;
|
final void Function(BuildContext context)? onOpenMemberList;
|
||||||
final void Function(BuildContext context) onOpenDrawer;
|
final void Function(BuildContext context) onOpenDrawer;
|
||||||
|
final String? roomId;
|
||||||
const RoomAppbar({
|
const RoomAppbar({
|
||||||
|
required this.roomId,
|
||||||
required this.isDesktop,
|
required this.isDesktop,
|
||||||
required this.onOpenDrawer,
|
required this.onOpenDrawer,
|
||||||
this.onOpenMemberList,
|
this.onOpenMemberList,
|
||||||
|
|
@ -23,13 +27,23 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final room = ref.watch(SelectedRoomController.provider);
|
final room = roomId == null
|
||||||
|
? null
|
||||||
|
: ref.watch(RoomsController.provider.select((value) => value[roomId!]));
|
||||||
return Appbar(
|
return Appbar(
|
||||||
leading: isDesktop
|
leading: isDesktop
|
||||||
? room == null
|
? room == null
|
||||||
? null
|
? null
|
||||||
: ExpandableImage(
|
: ExpandableImage(
|
||||||
room.metadata?.avatar?.toString(),
|
room.metadata?.avatar
|
||||||
|
?.mxcToHttps(
|
||||||
|
ref.watch(
|
||||||
|
ClientStateController.provider.select(
|
||||||
|
(value) => value!.homeserverUrl!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toString(),
|
||||||
child: AvatarOrHash(
|
child: AvatarOrHash(
|
||||||
room.metadata?.avatar,
|
room.metadata?.avatar,
|
||||||
room.metadata?.name ?? "Unnamed Rooms",
|
room.metadata?.name ?? "Unnamed Rooms",
|
||||||
448
lib/widgets/room_chat.dart
Normal file
448
lib/widgets/room_chat.dart
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/account_data_controller.dart";
|
||||||
|
import "package:nexus/controllers/client_controller.dart";
|
||||||
|
import "package:nexus/controllers/client_state_controller.dart";
|
||||||
|
import "package:nexus/controllers/power_level_controller.dart";
|
||||||
|
import "package:nexus/controllers/rooms_controller.dart";
|
||||||
|
import "package:nexus/controllers/room_chat_controller.dart";
|
||||||
|
import "package:nexus/controllers/via_controller.dart";
|
||||||
|
import "package:nexus/models/configs/power_level_config.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/relation_type.dart";
|
||||||
|
import "package:nexus/models/requests/report_request.dart";
|
||||||
|
import "package:nexus/widgets/composer/chat_box.dart";
|
||||||
|
import "package:nexus/widgets/emoji_picker_button.dart";
|
||||||
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
|
import "package:nexus/widgets/member_list.dart";
|
||||||
|
import "package:nexus/widgets/room_appbar.dart";
|
||||||
|
import "package:nexus/widgets/flash_wrapper.dart";
|
||||||
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
|
import "package:nexus/widgets/form_text_input.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
|
class RoomChat extends HookConsumerWidget {
|
||||||
|
final bool isDesktop;
|
||||||
|
final bool showMembersByDefault;
|
||||||
|
final String? roomId;
|
||||||
|
const RoomChat({
|
||||||
|
required this.roomId,
|
||||||
|
required this.isDesktop,
|
||||||
|
required this.showMembersByDefault,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final relatedEvent = useState<Event?>(null);
|
||||||
|
final relationType = useState(RelationType.reply);
|
||||||
|
final flashingEvent = useState<String?>(null);
|
||||||
|
|
||||||
|
final memberListOpened = useState<bool>(showMembersByDefault);
|
||||||
|
|
||||||
|
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (userId == null || this.roomId == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: RoomAppbar(
|
||||||
|
roomId: this.roomId,
|
||||||
|
isDesktop: isDesktop,
|
||||||
|
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
|
||||||
|
onOpenMemberList: null,
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Text(
|
||||||
|
"Nothing to see here...",
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final roomId = this.roomId!;
|
||||||
|
|
||||||
|
final controllerProvider = RoomChatController.provider(roomId);
|
||||||
|
final notifier = ref.watch(controllerProvider.notifier);
|
||||||
|
|
||||||
|
final client = ref.watch(ClientController.provider.notifier);
|
||||||
|
|
||||||
|
final listController = useRef(ListController());
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
Future<void> listener() async {
|
||||||
|
if (!scrollController.position.atEdge) return;
|
||||||
|
|
||||||
|
final room = ref.watch(
|
||||||
|
RoomsController.provider.select((value) => value[roomId]),
|
||||||
|
);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
if (scrollController.position.pixels == 0) {
|
||||||
|
await client.markRead(room);
|
||||||
|
} else {
|
||||||
|
if (room.hasMore) await notifier.loadOlder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(listener);
|
||||||
|
return () => scrollController.removeListener(listener);
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
final composerNode = useFocusNode(
|
||||||
|
onKeyEvent: (_, event) {
|
||||||
|
if (event is KeyDownEvent &&
|
||||||
|
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
|
relatedEvent.value = null;
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
IList<PopupMenuEntry> getEventOptions(Event event) {
|
||||||
|
final danger = theme.colorScheme.error;
|
||||||
|
final isSentByMe = event.sender == userId;
|
||||||
|
return [
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
PowerLevelConfig(eventType: EventType.reaction, roomId: roomId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
enabled: false,
|
||||||
|
child: IconTheme(
|
||||||
|
data: theme.iconTheme,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
...{
|
||||||
|
...ref.watch(
|
||||||
|
AccountDataController.provider.select(
|
||||||
|
(value) => IList(
|
||||||
|
value["m.recent_emoji"]
|
||||||
|
?.content["recent_emoji"] ??
|
||||||
|
[],
|
||||||
|
).map((entry) => entry["emoji"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"👍",
|
||||||
|
"🤣",
|
||||||
|
"😭",
|
||||||
|
"🤔",
|
||||||
|
}
|
||||||
|
.toIList()
|
||||||
|
.sublist(0, 4)
|
||||||
|
.map(
|
||||||
|
(emoji) => IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await notifier
|
||||||
|
.sendReaction(emoji, event)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
icon: Text(emoji),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
EmojiPickerButton(
|
||||||
|
context: context,
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
onSelection: (emoji) =>
|
||||||
|
notifier.sendReaction(emoji, event).onError(showError),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
PowerLevelConfig(eventType: EventType.message, roomId: roomId),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
relatedEvent.value = event;
|
||||||
|
relationType.value = RelationType.reply;
|
||||||
|
composerNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
|
||||||
|
),
|
||||||
|
if (event.content is MessageContent && isSentByMe)
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
relatedEvent.value = event;
|
||||||
|
relationType.value = RelationType.edit;
|
||||||
|
composerNode.requestFocus();
|
||||||
|
},
|
||||||
|
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () async {
|
||||||
|
final room = ref.watch(
|
||||||
|
RoomsController.provider.select((value) => value[roomId]),
|
||||||
|
);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
final vias = ref.watch(ViaController.provider(room));
|
||||||
|
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text:
|
||||||
|
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
|
||||||
|
),
|
||||||
|
if (ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
PowerLevelConfig.redaction(
|
||||||
|
targetUser: event.sender,
|
||||||
|
roomId: roomId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => HookBuilder(
|
||||||
|
builder: (_) {
|
||||||
|
final deleteReasonController = useTextEditingController();
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text("Delete Message"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Are you sure you want to delete this message? This can not be reversed.",
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
FormTextInput(
|
||||||
|
required: false,
|
||||||
|
capitalize: true,
|
||||||
|
controller: deleteReasonController,
|
||||||
|
title: "Reason for deletion (optional)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await notifier
|
||||||
|
.deleteMessage(
|
||||||
|
event,
|
||||||
|
reason: deleteReasonController.text,
|
||||||
|
)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
child: Text("Delete"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete, color: danger),
|
||||||
|
title: Text("Delete", style: TextStyle(color: danger)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => HookBuilder(
|
||||||
|
builder: (_) {
|
||||||
|
final reasonController = useTextEditingController();
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text("Report"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Report this event to your server administrators, who can take action like banning this server or room.",
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: 12),
|
||||||
|
FormTextInput(
|
||||||
|
required: false,
|
||||||
|
capitalize: true,
|
||||||
|
controller: reasonController,
|
||||||
|
title: "Reason for report (optional)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
client.reportEvent(
|
||||||
|
ReportRequest(
|
||||||
|
roomId: roomId,
|
||||||
|
eventId: event.eventId,
|
||||||
|
reason: reasonController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: reasonController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text("Report"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.report, color: danger),
|
||||||
|
title: Text("Report", style: TextStyle(color: danger)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].toIList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final controllerData = ref.watch(controllerProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: RoomAppbar(
|
||||||
|
roomId: roomId,
|
||||||
|
isDesktop: isDesktop,
|
||||||
|
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
|
||||||
|
onOpenMemberList: (thisContext) {
|
||||||
|
memberListOpened.value = !memberListOpened.value;
|
||||||
|
Scaffold.of(thisContext).openEndDrawer();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: switch (controllerData) {
|
||||||
|
AsyncData(:final value) ||
|
||||||
|
AsyncLoading(:final value?) => CustomScrollView(
|
||||||
|
reverse: true,
|
||||||
|
controller: scrollController,
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsetsGeometry.only(bottom: 64),
|
||||||
|
),
|
||||||
|
|
||||||
|
SuperSliverList.builder(
|
||||||
|
listController: listController.value,
|
||||||
|
itemCount: value.length,
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final event = value[index];
|
||||||
|
final previousEvent = value.getOrNull(index + 1);
|
||||||
|
return FlashWrapper(
|
||||||
|
EventRenderer(
|
||||||
|
event,
|
||||||
|
onTapReply: () async {
|
||||||
|
final replyId = event.replyTo;
|
||||||
|
listController.value.animateToItem(
|
||||||
|
index: value.indexWhere(
|
||||||
|
(element) => element.eventId == replyId,
|
||||||
|
),
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) =>
|
||||||
|
Duration(milliseconds: 700),
|
||||||
|
curve: (_) => Curves.easeInOut,
|
||||||
|
);
|
||||||
|
flashingEvent.value = replyId;
|
||||||
|
await Future.delayed(
|
||||||
|
Duration(seconds: 1),
|
||||||
|
() {
|
||||||
|
if (flashingEvent.value == replyId) {
|
||||||
|
flashingEvent.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getEventOptions: getEventOptions,
|
||||||
|
isGrouped:
|
||||||
|
previousEvent?.content
|
||||||
|
is MessageContent &&
|
||||||
|
event.redactedBy == null &&
|
||||||
|
event.relationType != "m.replace" &&
|
||||||
|
"${event.sender}${event.pmp?.id}" ==
|
||||||
|
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
|
||||||
|
),
|
||||||
|
isFlashing:
|
||||||
|
flashingEvent.value == event.eventId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 36),
|
||||||
|
child: Center(
|
||||||
|
child: controllerData is AsyncLoading
|
||||||
|
? Loading()
|
||||||
|
: ElevatedButton(
|
||||||
|
onPressed: notifier.loadOlder,
|
||||||
|
child: Text("Load More"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
AsyncLoading() => Loading(),
|
||||||
|
AsyncError(:final error, :final stackTrace) =>
|
||||||
|
ErrorDialog(error, stackTrace),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ChatBox(
|
||||||
|
roomId,
|
||||||
|
node: composerNode,
|
||||||
|
onSend: (text, {required shouldMention, required tags}) =>
|
||||||
|
notifier
|
||||||
|
.send(
|
||||||
|
text,
|
||||||
|
tags: tags,
|
||||||
|
relationType: relationType.value,
|
||||||
|
shouldMention: shouldMention,
|
||||||
|
relation: relatedEvent.value,
|
||||||
|
)
|
||||||
|
.onError(showError),
|
||||||
|
relationType: relationType.value,
|
||||||
|
relatedEvent: relatedEvent.value,
|
||||||
|
onDismiss: () => relatedEvent.value = null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (memberListOpened.value == true && showMembersByDefault)
|
||||||
|
MemberList(roomId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue