Squashed commit of the following:

commit 228ff1051f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 16:28:47 2026 -0400

    Add load more button

commit 2451555479
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 16:17:21 2026 -0400

    add reaction support

commit a28592d11e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 14:19:51 2026 -0400

    change algorithm for deciding when to load more messages

commit 7016cc4205
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 14:17:01 2026 -0400

    change wording on verify page

    message -> event

commit e4f091cb0f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 14:07:21 2026 -0400

    Add GenericEventRenderer

commit 49d480d1e6
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 13:11:04 2026 -0400

    remove extra backslash that was breaking link regex

commit 7850117cb6
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 12:49:01 2026 -0400

    abstract ColorHash into its own extension

commit fd5eaa2725
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 12:40:26 2026 -0400

    fix audio player size

commit 1834ae2c5b
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 12:35:31 2026 -0400

    fix timeline sorting

commit 13c2a4062b
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 12:26:41 2026 -0400

    make it a little more efficient

commit 57cfad9f45
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 12:16:07 2026 -0400

    Revert "temp isolate"

    This reverts commit 34e6c07d8d.

commit 34e6c07d8d
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 12:16:01 2026 -0400

    temp isolate

commit e00cd12bb9
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Thu May 21 11:24:18 2026 -0400

    some performance improvements

commit e76c0aac16
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 16:30:26 2026 -0400

    Slightly up padding for event preview

commit 8356719f8f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 16:10:47 2026 -0400

    fix wrong colors on membership rendering

commit d010faea4a
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 16:09:49 2026 -0400

    grammar fixes for membership rendering

commit cff580dee2
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 16:06:17 2026 -0400

    general fixups, plus adding colors for names

commit ccd8513cde
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 13:28:42 2026 -0400

    reorganize files

commit 6e0dd8c33d
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 13:27:00 2026 -0400

    update readme

commit df5040e06c
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 12:33:14 2026 -0400

    remove selected room/space controllers

commit 0653961f9c
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 11:07:49 2026 -0400

    reactive members controller, better caching, fixes #6, fixes #7

commit e59505bd6e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:41:18 2026 -0400

    Make room type into an enum

commit fc6ca5b454
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:38:10 2026 -0400

    Make message format an enum

commit 17a1af0b73
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:35:13 2026 -0400

    change reply card color to not match message card

commit 740ab2fb9f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:26:33 2026 -0400

    fix passing an mxc to expandableimage

commit 5a9e29be34
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:22:09 2026 -0400

    ignore pointer for reply preview

commit ffdcc89de0
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:20:17 2026 -0400

    fix incorrectly ellipsised messages

commit 8d3b94bc40
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:17:49 2026 -0400

    fix showing the wrong user in reply preview

commit f085d04f67
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 20 10:16:52 2026 -0400

    Fix showing link previews in reply preview

commit d746f40778
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 23:44:02 2026 -0400

    fix nix build

commit 3aec4c3080
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 23:03:34 2026 -0400

    lower padding for groups

commit 81aead26cc
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 23:02:40 2026 -0400

    fix grouping logic

commit 922c624d4e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 22:57:04 2026 -0400

    some indentation fixes in event renderer

commit 5c6cc1d584
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 22:55:29 2026 -0400

    fix PMP rendering, add grouping

commit e7bcf956e3
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 22:46:36 2026 -0400

    improve legibility of content parsing

commit dbef2d709b
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 22:37:17 2026 -0400

    fix double reply preview

commit 0da5e8beac
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 21:57:55 2026 -0400

    fix overflow of reply preview

commit c4255f340a
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 21:54:21 2026 -0400

    Support for loading history and marking read

commit 200ce2285c
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 21:45:51 2026 -0400

    limit size of loading indicator for link previews

commit 150de1a669
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 21:45:03 2026 -0400

    Change scroll animation length

commit a72d696f49
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 21:44:03 2026 -0400

    working reply rendering

commit 7761ca73fd
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 21:18:27 2026 -0400

    working edits

commit 734e7f57df
Author: 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

commit 1305320a1a
Author: 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

commit bbd157a584
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:57:01 2026 -0400

    use raw string for link regex

commit 94df2dc68f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:53:57 2026 -0400

    add a WIP comment for location messages

commit 2344ed887d
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:45:39 2026 -0400

    Add file card

commit 613e74ea33
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:25:41 2026 -0400

    remove chat_page directory, move relevant files

commit 35f5d4e849
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:21:37 2026 -0400

    refactor membership renderer into its own widget

commit 1cc2c87ae8
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:16:33 2026 -0400

    rename render_event to event_renderer

commit 32aff5b4b1
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:12:59 2026 -0400

    increase link preview padding

commit 551bec7982
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 19:11:23 2026 -0400

    add custom audio player widget

commit 5c2f8fa014
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 18:47:30 2026 -0400

    more reliable video playback

commit ce15add4e7
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 17:04:19 2026 -0400

    turn up buffer size for video

commit 13f52a3989
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 16:37:48 2026 -0400

    fix incorrect popover user for membership events

commit 8010c3467e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 16:37:18 2026 -0400

    add video player, might need tweaking to get perfect

commit 1a4ef800c6
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 16:05:45 2026 -0400

    placeholder widget for video support

commit 211c088df9
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 13:37:02 2026 -0400

    fix extra memberships

commit b3d1dc81b5
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 12:16:33 2026 -0400

    add membership rendering

commit f9b1960cf8
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 11:23:38 2026 -0400

    support for m.emote msgtype

commit b9e42ec51b
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 11:05:40 2026 -0400

    constrain images to a max size

commit b71ebe5fee
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 11:00:59 2026 -0400

    fixup image rendering, prettier rendering for UTDs

commit e7ecae4606
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 10:56:05 2026 -0400

    don't try to render redacted events

commit 6534e2d46e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 10:42:59 2026 -0400

    change embed color

commit df491b2ed3
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 10:40:16 2026 -0400

    fix decryption

commit fee12cb94d
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 19 10:07:15 2026 -0400

    fix up url embeds

commit 8aae2c29cb
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 14:52:53 2026 -0400

    Working image rendering

commit 2aae141c27
Author: 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

commit c9b5b3dda8
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 14:30:44 2026 -0400

    various fixes

commit 061c280387
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 14:21:36 2026 -0400

    make timestamps flexible not expanded

commit cb20cb38fd
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 14:20:35 2026 -0400

    text message rendering

commit fd46dbda69
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 13:02:20 2026 -0400

    fix defaults if power level event malformed

commit 22f9e61c7c
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 12:57:30 2026 -0400

    fix power level logic

commit 5d1db60a9f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 10:54:58 2026 -0400

    remove now unused method on room chat controller

commit 9303fee0de
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 10:49:31 2026 -0400

    fix memberships constantly reloading

commit 46d7ec4202
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 10:25:13 2026 -0400

    fix avatar parsing

commit a5ddce3d08
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 10:17:15 2026 -0400

    fix padding

commit 14ec487bbe
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Mon May 18 10:12:53 2026 -0400

    fix error handling in models

commit cb22ed9822
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 22:24:41 2026 -0400

    fix up bugs related to new architecture

commit 8d9645b460
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 21:42:14 2026 -0400

    fix room watch

commit 292a219ed2
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 21:37:43 2026 -0400

    pattern matching is awesome

commit c65e8e0562
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 21:33:38 2026 -0400

    fix displayname widget

commit 91d573e487
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 21:32:49 2026 -0400

    fix constant refreshing

commit 161a9d2f13
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 21:25:04 2026 -0400

    Displaying something now

    Just Event IDs so far

commit cf5d1ad5d9
Author: 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

commit 1fa050e7ae
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 18:41:34 2026 -0400

    flesh out EventText a little more

commit 0be5336065
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 17:58:02 2026 -0400

    add a placeholder EventText widget

commit a2e0b6bdb1
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sun May 17 16:42:12 2026 -0400

    add assertion for PowerLevelConfig.redaction

commit ad14f2207e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 21:21:33 2026 -0400

    Fixes to power level controller

commit 49c09b3c35
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 16:22:49 2026 -0400

    easy widgets ported to use new event format

commit 788900d852
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 15:35:45 2026 -0400

    fix all helpers

commit d0b148ad5b
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 15:24:05 2026 -0400

    port all controllers to new event format

commit 94f0d9e346
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 11:33:38 2026 -0400

    add reaction content type

commit 05b15c44ec
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 11:09:05 2026 -0400

    add pinned events content type

commit 7e2c90381c
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 16 11:03:29 2026 -0400

    add quite a few more content types

commit 17603f0d16
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Fri May 15 21:18:05 2026 -0400

    add join rules event

commit e60e247093
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Fri May 15 20:59:30 2026 -0400

    add create event

commit 3ce1f53bc4
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Fri May 15 20:14:17 2026 -0400

    enum for event type

commit 3325ebcad7
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Fri May 15 20:07:08 2026 -0400

    implement all msgtypes

commit 66356202c0
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 13 15:45:34 2026 -0400

    good framework for content models

commit 6af56ccb3e
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 13 14:32:23 2026 -0400

    Revert "possible way to union event"

    This reverts commit b3db9bea6f.

commit b3db9bea6f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Wed May 13 14:32:12 2026 -0400

    possible way to union event

commit c520516d51
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 12 20:50:55 2026 -0400

    treewide replace authorId with sender

commit 881c76359b
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 12 20:32:40 2026 -0400

    custom link previews

commit cee1298b62
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 12 20:08:55 2026 -0400

    add back custom blurhashing

commit 8bdc1060d3
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 12 19:51:05 2026 -0400

    remove flutter_chat

commit 25888144a6
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Tue May 12 19:27:23 2026 -0400

    small fixups

commit fb3b19a27f
Author: Henry-Hiles <henry@henryhiles.com>
Date:   Sat May 9 17:48:20 2026 -0400

    Reapply "WIP removal of new_events_controller"

    This reverts commit 4dc16a5529.
This commit is contained in:
Henry Hiles 2026-05-21 16:58:22 -04:00
commit 3a280719d0
111 changed files with 3162 additions and 2366 deletions

View file

@ -6,7 +6,9 @@
"Gomuks",
"Homeserver",
"localpart",
"msgtype",
"muks",
"prefs"
"prefs",
"unban"
]
}

View file

@ -15,9 +15,6 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
## 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
- [x] Linux
- [ ] 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.to link
- [ ] From space
- [ ] Exploring
- [ ] From directory
- [x] Leaving
- [x] Subspaces
- [x] Messages
@ -116,6 +113,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] Settings
- [ ] Matrix: URIs vs Matrix.to links
- [ ] Light/Dark mode
- [ ] Remote Gomuks instance
- [ ] SSD or CSD
- [ ] Align your message bubbles to left or right
- [ ] Show media by default

12
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1777988971,
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@ -88,11 +88,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1777954456,
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {

View file

@ -1,47 +1,31 @@
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:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/user_controller.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/membership.dart";
import "package:nexus/models/membership_status.dart";
import "package:nexus/models/configs/user_config.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/event.dart";
class AuthorController extends AsyncNotifier<Membership> {
final Message message;
AuthorController(this.message);
class AuthorController extends AsyncNotifier<MembershipContent> {
final Event event;
AuthorController(this.event);
@override
Future<Membership> build() async {
Future<MembershipContent> build() async {
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
? null
: Membership.fromContent(
IMap(message.metadata?["pmp"]),
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,
return MembershipContent(
status: member.status,
avatarUrl: event.pmp?.avatarUrl ?? member.avatarUrl,
displayName: event.pmp?.displayName ?? member.displayName,
);
}
static final provider =
AsyncNotifierProvider.family<AuthorController, Membership, Message>(
AsyncNotifierProvider.family<AuthorController, MembershipContent, Event>(
AuthorController.new,
);
}

View file

@ -1,15 +1,13 @@
import "dart:developer";
import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "package:collection/collection.dart";
import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/client_state_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/space_edges_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/main.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/paginate.dart";
import "package:nexus/models/requests/get_event_request.dart";
@ -81,12 +80,8 @@ class ClientController extends AsyncNotifier<int> {
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
if (event.type == "m.room.message") {
ref
.watch(
NewEventsController.provider(event.roomId).notifier,
)
.add(IList([event]));
if (event.type == EventType.message.type) {
// ref.watch(provider.notifier).addEvent(event); TODO
}
break;
case "sync_complete":
@ -127,9 +122,12 @@ class ClientController extends AsyncNotifier<int> {
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
debugger();
showError(error, stackTrace);
if (kDebugMode) {
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 {
final event = request.room.events.firstWhereOrNull(
(event) => event.eventId == request.eventId,
);
if (event != null) return event;
final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : Event.fromJson(json);
}
@ -232,8 +225,10 @@ class ClientController extends AsyncNotifier<int> {
Future<Paginate> paginate(PaginateRequest request) async =>
Paginate.fromJson(await _sendCommand("paginate", request.toJson()));
Future<Profile> getProfile(String userId) async =>
Profile.fromJson(await _sendCommand("get_profile", {"user_id": userId}));
Future<Profile> getProfile(String userId) async => Profile.fromJsonWithCatch({
...(await _sendCommand("get_profile", {"user_id": userId})),
"id": userId,
});
Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson());
@ -242,9 +237,8 @@ class ClientController extends AsyncNotifier<int> {
_sendCommand("set_membership", request.toJson());
Future<void> markRead(Room room) async {
final event = room.events.firstWhereOrNull(
(event) => event.rowId == room.timeline.last.eventRowId,
);
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId];
if (event == null || room.metadata == null) return;
await _sendCommand("mark_read", {

View file

@ -1,5 +1,7 @@
import "package:collection/collection.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/event.dart";
import "package:nexus/models/requests/get_event_request.dart";
@ -9,8 +11,18 @@ class EventController extends AsyncNotifier<Event?> {
@override
Future<Event?> build() async {
final client = ref.watch(ClientController.provider.notifier);
return await client.getEvent(request).onError((_, _) => null);
final room = ref.watch(
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

View file

@ -12,14 +12,14 @@ class KeyController extends Notifier<String?> {
String? build() =>
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;
state = id;
state = value;
if (id == null) {
if (value == null) {
prefs.remove(key);
} else {
prefs.setString(key, id);
prefs.setString(key, value);
}
}

View 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);
}

View file

@ -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);
}

View file

@ -1,52 +1,46 @@
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/client_state_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/models/membership.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/get_room_state_request.dart";
class MembersController extends AsyncNotifier<IList<Membership>> {
@override
Future<IList<Membership>> build() async {
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();
class MembersController extends AsyncNotifier<ISet<Event>> {
final String roomId;
MembersController(this.roomId);
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)
.getRoomState(
GetRoomStateRequest(
roomId: data.$1,
fetchMembers: data.$2 == false,
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList ?? true,
includeMembers: true,
),
);
return state.nonNulls
.where((state) => state.type == "m.room.member")
.map(
(membership) => Membership.fromContent(
membership.content,
membership.stateKey!,
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
),
) ??
"",
),
)
.toIList();
await ref
.read(RoomsController.provider.notifier)
.addState(roomId, fetchedState, isMembers: true);
}
static final provider =
AsyncNotifierProvider<MembersController, IList<Membership>>(
MembersController.new,
);
return room.state[EventType.membership.type]?.values
.map((rowId) => room.events[rowId])
.nonNulls
.toISet() ??
const ISet.empty();
}
static final provider = AsyncNotifierProvider.autoDispose
.family<MembersController, ISet<Event>, String>(MembersController.new);
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

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

View file

@ -1,8 +1,9 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.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/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/requests/membership_action.dart";
class PowerLevelController extends Notifier<bool> {
@ -11,58 +12,62 @@ class PowerLevelController extends Notifier<bool> {
@override
bool build() {
final room = ref.watch(SelectedRoomController.provider);
final event = room?.events.firstWhereOrNull(
(event) => event.rowId == room.state["m.room.power_levels"]?[""],
if (config case EventPowerLevelConfig(:final eventType)) {
assert(
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 events = (event.content["events"] as Map<String, dynamic>? ?? {});
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
);
int powerLevelOf(String userId) => users.containsKey(userId)
? (users[userId] as int)
: (event.content["users_default"] as int? ?? 0);
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
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 targetLevel = config.targetUser != null
? powerLevelOf(config.targetUser!)
: null;
if (config.action != null) {
return switch (config.action!) {
MembershipAction.invite =>
userLevel >= (event.content["invite"] as int? ?? 0),
return switch (config) {
EventPowerLevelConfig(:final eventType) =>
userLevel >= (content.events[eventType.type] ?? content.eventsDefault),
MembershipActionPowerLevelConfig(:final action, :final targetUser) =>
switch (action) {
MembershipAction.invite => userLevel >= content.invite,
MembershipAction.kick =>
targetLevel != null &&
userLevel >= (event.content["kick"] as int? ?? 50) &&
userLevel > targetLevel,
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
MembershipAction.ban =>
targetLevel != null &&
userLevel >= (event.content["ban"] as int? ?? 50) &&
userLevel > targetLevel,
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
MembershipAction.unban =>
userLevel >= (event.content["ban"] as int? ?? 50),
MembershipAction.unban => userLevel >= content.ban,
},
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
.family<PowerLevelController, bool, PowerLevelConfig>(
PowerLevelController.new,

View file

@ -12,6 +12,8 @@ class ProfileController extends AsyncNotifier<Profile> {
return client.getProfile(userId);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ProfileController, Profile, String>(ProfileController.new);
static final provider =
AsyncNotifierProvider.family<ProfileController, Profile, String>(
ProfileController.new,
);
}

View 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);
}

View file

@ -1,17 +1,13 @@
import "dart:async";
import "dart:math";
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:fluttertagger/fluttertagger.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/selected_room_controller.dart";
import "package:nexus/models/configs/messages_config.dart";
import "package:nexus/models/configs/message_config.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/event.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/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/room.dart";
class RoomChatController extends AsyncNotifier<InMemoryChatController> {
class RoomChatController extends AsyncNotifier<IList<Event>> {
final String roomId;
RoomChatController(this.roomId);
@override
Future<InMemoryChatController> build() async {
Future<IList<Event>> build() async {
final client = ref.watch(ClientController.provider.notifier);
var room = ref.read(RoomsController.provider)[roomId];
if (room == null) return InMemoryChatController();
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return const IList.empty();
if (!room.hasFetchedState) {
final state = await client.getRoomState(
GetRoomStateRequest(roomId: roomId),
);
ref
.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,
),
);
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
}
if (event.type == "m.room.redaction") {
final controller = await future;
final redactsId = event.content["redacts"];
final originalMessage = controller.messages.firstWhereOrNull(
(message) => message.id == redactsId,
);
if (!ref.mounted) return;
if (originalMessage != null) {
return await controller.removeMessage(originalMessage);
// While there are under 20 events, try to load more
// until there's no more or the conditions are met.
if (room.hasMore && room.timeline.length < 20) {
loadOlder();
}
final redacts = ref
.read(SelectedRoomController.provider)
?.events
.firstWhere((event) => event.eventId == redactsId);
return room.timeline
.toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0))
.map((entry) {
if (entry.value == null) return null;
if (redacts?.type == "m.reaction") {
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;
final foundEvent = room.events[entry.value!];
return await controller.updateMessage(
message,
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
final editedEvent =
foundEvent == null || foundEvent.lastEditRowId == 0
? null
: controller.messages.firstWhereOrNull(
(element) =>
element.metadata?["txnId"] == message.metadata?["txnId"],
);
: room.events[foundEvent.lastEditRowId];
return oldMessage == null
? controller.insertMessage(message)
: controller.updateMessage(oldMessage, message);
return editedEvent == null
? foundEvent
: 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)
.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
.watch(ClientController.provider.notifier)
.paginate(
PaginateRequest(
roomId: roomId,
maxTimelineId: ref
.read(RoomsController.provider)[roomId]
?.timeline
.firstOrNull
?.timelineRowId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
),
);
@ -226,42 +94,22 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
.update(
IMap({
roomId: Room(
events: response.events.addAll(response.relatedEvents),
hasMore: response.hasMore,
timeline: response.events
.map(
(event) => TimelineRowTuple(
timelineRowId: event.timelineRowId,
eventRowId: event.rowId,
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
)
.toIList(),
),
}),
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;
}
@ -270,7 +118,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
bool shouldMention = true,
required IList<Tag> tags,
required RelationType relationType,
Message? relation,
Event? relation,
}) async {
var taggedMessage = text;
@ -285,7 +133,6 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
}
final client = ref.watch(ClientController.provider.notifier);
final room = ref.read(RoomsController.provider)[roomId];
final event = await client.sendMessage(
SendMessageRequest(
roomId: roomId,
@ -294,52 +141,46 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
if (shouldMention == true &&
relation != null &&
relationType == RelationType.reply)
relation.authorId,
relation.sender,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
relation: relation == null
? null
: Relation(eventId: relation.id, 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},
: Relation(eventId: relation.eventId, relationType: relationType),
),
);
await setFlashing(true);
Timer(Duration(seconds: 1), () => setFlashing(false));
return await controller.scrollToMessage(message.id);
// TODO: Add new event to timeline whilst its sending
// ref
// .watch(RoomsController.provider.notifier)
// .update(
// {
// roomId: Room(
// events: [event].toIList(),
// timeline: [
// TimelineRowTuple(
// timelineRowId: event.timelineRowId,
// eventRowId: event.rowId,
// ),
// ].toIList(),
// ),
// }.toIMap(),
// const ISet.empty(),
// );
}
Future<void> removeReaction(
String reaction,
Message message,
Event event,
String userId,
) async {
final client = ref.watch(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
GetRelatedEventsRequest(
roomId: roomId,
eventId: message.id,
eventId: event.eventId,
relationType: "m.annotation",
),
);
@ -349,9 +190,11 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
.toIList();
final reactionEvent = reactionEvents?.firstWhereOrNull(
(event) =>
event.authorId == userId &&
event.content["m.relates_to"]?["key"] == reaction,
(event) => switch (event.content) {
ReactionContent(:final key) =>
key == reaction && event.sender == userId,
_ => false,
},
);
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);
await client.sendEvent(
@ -372,7 +215,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
type: "m.reaction",
content: {
"m.relates_to": {
"event_id": message.id,
"event_id": event.eventId,
"rel_type": "m.annotation",
"key": reaction,
},
@ -384,7 +227,7 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, InMemoryChatController, String>(
.autoDispose<RoomChatController, IList<Event>, String>(
RoomChatController.new,
);
}

View file

@ -1,9 +1,8 @@
import "package:collection/collection.dart";
import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart";
@ -11,55 +10,50 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => const IMap.empty();
void update(
IMap<String, Room> rooms,
ISet<String> leftRooms, {
bool addToNewEvents = true,
}) {
final homeserver =
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
Future<void> addState(
String roomId,
IList<Event> state, {
bool isMembers = false,
}) async => update(
{
roomId: Room(
events: IMap.fromEntries(
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 roomId = entry.key;
final incoming = entry.value;
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(
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
metadata:
incoming.metadata?.copyWith(
avatar:
incoming.metadata?.avatar?.mxcToHttps(homeserver) ??
existing.metadata?.avatar,
) ??
existing.metadata,
events: events!,
metadata: incoming.metadata ?? existing.metadata,
events: incoming.events.isEmpty
? existing.events
: existing.events.addAll(incoming.events),
state: incoming.state.entries.fold(
existing.state,
(previousValue, event) => previousValue.add(
@ -69,15 +63,14 @@ class RoomsController extends Notifier<IMap<String, Room>> {
),
),
),
timeline:
(incoming.reset
reset: false,
hasFetchedMembers:
incoming.hasFetchedMembers || existing.hasFetchedMembers,
hasFetchedState:
incoming.hasFetchedState || existing.hasFetchedState,
timeline: (incoming.reset
? incoming.timeline
: existing.timeline.updateById(
incoming.timeline,
(item) => item.timelineRowId,
))
.sortedBy((element) => element.timelineRowId)
.toIList(),
: existing.timeline.addAll(incoming.timeline)),
receipts: incoming.receipts.entries.fold(
existing.receipts,
(receiptAcc, event) => receiptAcc.add(
@ -88,11 +81,7 @@ class RoomsController extends Notifier<IMap<String, Room>> {
),
),
) ??
incoming.copyWith(
metadata: incoming.metadata?.copyWith(
avatar: incoming.metadata?.avatar?.mxcToHttps(homeserver),
),
),
incoming,
);
});
@ -100,6 +89,7 @@ class RoomsController extends Notifier<IMap<String, Room>> {
merged,
(acc, roomId) => acc.remove(roomId),
);
state = prunedList;
}

View file

@ -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,
);
}

View file

@ -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,
);
}

View file

@ -1,18 +1,20 @@
import "dart:convert";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/header_controller.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;
UrlPreviewController(this.link);
@override
Future<LinkPreviewData?> build() async {
final homeserver = ref.watch(ClientStateController.provider)?.homeserverUrl;
Future<OpenGraphData?> build() async {
final homeserver = ref.watch(
ClientStateController.provider.select((value) => value?.homeserverUrl),
);
if (homeserver != null && !link.contains("matrix.to")) {
{
@ -25,27 +27,14 @@ class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
if (response.statusCode == 200) {
final decodedValue = json.decode(response.body);
if (decodedValue is! Map<String, dynamic>) return null;
final mxc = decodedValue["og:image"];
final image = mxc == null
? null
: Uri.tryParse(mxc)?.mxcToHttps(homeserver);
return LinkPreviewData(
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,
),
);
return OpenGraphData.fromJson(decodedValue).copyWith(imageUrl: image);
}
}
}
@ -54,7 +43,7 @@ class UrlPreviewController extends AsyncNotifier<LinkPreviewData?> {
}
static final provider = AsyncNotifierProvider.autoDispose
.family<UrlPreviewController, LinkPreviewData?, String>(
.family<UrlPreviewController, OpenGraphData?, String>(
UrlPreviewController.new,
);
}

View file

@ -4,37 +4,44 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/controllers/profile_controller.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";
class UserController extends AsyncNotifier<Membership?> {
final String userId;
UserController(this.userId);
class UserController extends AsyncNotifier<MembershipContent> {
final UserConfig config;
UserController(this.config);
@override
Future<Membership?> build() async {
final member = await ref.watch(
MembersController.provider.selectAsync(
(value) =>
value.firstWhereOrNull((membership) => membership.userId == userId),
Future<MembershipContent> build() async {
final member = config.roomId == null
? null
: await ref.watch(
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);
return Membership(
final profile = await ref.watch(
ProfileController.provider(config.userId).future,
);
return MembershipContent(
status: MembershipStatus.leave,
avatarUrl: profile.avatarUrl == null
? null
: Uri.tryParse(profile.avatarUrl!),
displayName: profile.displayName ?? userId.localpart,
userId: userId,
avatarUrl: profile.avatarUrl,
displayName: profile.displayName ?? config.userId.localpart,
);
}
static final provider =
AsyncNotifierProvider.family<UserController, Membership?, String>(
UserController.new,
);
AsyncNotifierProvider.family<
UserController,
MembershipContent,
UserConfig
>(UserController.new);
}

View file

@ -2,6 +2,10 @@ import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_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";
class ViaController extends Notifier<String> {
@ -21,24 +25,30 @@ class ViaController extends Notifier<String> {
addUserId(ref.watch(ClientStateController.provider)?.userId);
final powerLevels = room.events.firstWhereOrNull(
(event) => event.rowId == room.state["m.room.power_levels"]?[""],
);
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
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);
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++) {
final member = room.events.firstWhereOrNull(
(event) => event.rowId == members?.getOrNull(i),
);
final membershipEventId = 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);
}
}
if (members?.getOrNull(i) == null) break;
}

View file

@ -1,3 +1,3 @@
extension GetLocalpart on String {
String get localpart => substring(1).split(":").first;
String get localpart => length > 1 ? substring(1).split(":").first : "?";
}

View file

@ -1,17 +1,23 @@
import "package:flutter/material.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/membership.dart";
import "package:nexus/widgets/chat_page/user_popover.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/user_popover.dart";
extension ShowUserPopover on BuildContext {
void showUserPopover(Membership member, {required Offset globalPosition}) =>
showContextMenu(
void showUserPopover(
MembershipContent member,
String userId, {
required Offset globalPosition,
}) => showContextMenu(
globalPosition: globalPosition,
children: [
PopupMenuItem(
enabled: false,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: IconTheme(data: IconThemeData(), child: UserPopover(member)),
child: IconTheme(
data: IconThemeData(),
child: UserPopover(member, userId),
),
),
],
);

View 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]}";
}
}

View 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;
}

View file

@ -2,6 +2,7 @@ import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/header_controller.dart";
@ -56,6 +57,7 @@ void showError(Object error, [StackTrace? stackTrace]) {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized();

View 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);
}

View file

@ -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);
}

View file

@ -1,17 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
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);
}

View file

@ -1,17 +1,28 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/membership_action.dart";
part "power_level_config.freezed.dart";
part "power_level_config.g.dart";
@freezed
abstract class PowerLevelConfig with _$PowerLevelConfig {
sealed class PowerLevelConfig with _$PowerLevelConfig {
const factory PowerLevelConfig({
@Default(false) bool isStateEvent,
required String eventType,
MembershipAction? action,
String? targetUser,
}) = _PowerLevelConfig;
required EventType eventType,
required String roomId,
}) = EventPowerLevelConfig;
factory PowerLevelConfig.fromJson(Map<String, Object?> json) =>
_$PowerLevelConfigFromJson(json);
const factory PowerLevelConfig.membershipAction({
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;
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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,
}

View 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);
}

View 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,
}

View 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);
}

View 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);
}

View file

@ -0,0 +1,36 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View file

@ -1,37 +1,69 @@
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/epoch_date_time_converter.dart";
import "package:nexus/models/profile.dart";
part "event.freezed.dart";
part "event.g.dart";
@freezed
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({
@JsonKey(name: "rowid") required int rowId,
@JsonKey(name: "timeline_rowid") required int timelineRowId,
required String roomId,
required String eventId,
@JsonKey(name: "sender") required String authorId,
required String type,
required String sender,
@JsonKey(readValue: Event.typeJsonFromJson) required String type,
String? stateKey,
@EpochDateTimeConverter() required DateTime timestamp,
required IMap<String, dynamic> content,
IMap<String, dynamic>? decrypted,
String? decryptedType,
@Default(IMap.empty()) IMap<String, dynamic> unsigned,
LocalContent? localContent,
String? transactionId,
String? redactedBy,
String? relatesTo,
String? relationType,
String? replyTo,
String? decryptionError,
String? sendError,
@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,
Profile? pmp,
required Content content,
required Content? previousContent,
}) = _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

View 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
View 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);
}

View 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);
}

View 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);
}

View file

@ -0,0 +1,4 @@
import "package:freezed_annotation/freezed_annotation.dart";
@JsonEnum(fieldRename: FieldRename.snake)
enum JoinRule { public, knock, invite, private, restricted, knockRestricted }

View file

@ -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,
);
}

View file

@ -1,4 +1,4 @@
import "package:freezed_annotation/freezed_annotation.dart";
@JsonEnum()
enum MembershipStatus { leave, invite, ban, join }
enum MembershipStatus { leave, invite, ban, join, knock }

View 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;
}

View 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);
}

View file

@ -12,18 +12,28 @@ Object? readTimezone(Map<dynamic, dynamic> map, _) =>
@freezed
abstract class Profile with _$Profile {
const factory Profile({
String? avatarUrl,
required String id,
String? parseError,
Uri? avatarUrl,
@JsonKey(name: "displayname") String? displayName,
@JsonKey(readValue: readTimezone) String? timezone,
@JsonKey(readValue: readTimezone, name: "m.tz") String? timezone,
@Default(IList.empty())
@JsonKey(readValue: readPronouns)
@JsonKey(readValue: readPronouns, name: "io.fsky.nyx.pronouns")
IList<Pronoun> pronouns,
}) = _Profile;
factory Profile.fromJson(Map<String, Object?> json) =>
factory Profile.fromJson(Map<String, dynamic> 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

View file

@ -1,32 +1,16 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart";
part "get_event_request.freezed.dart";
part "get_event_request.g.dart";
@Freezed(toJson: false)
@Freezed()
abstract class GetEventRequest with _$GetEventRequest {
const GetEventRequest._();
const factory GetEventRequest({
required Room room,
required String roomId,
required String eventId,
@Default(false) bool unredact,
}) = _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) =>
_$GetEventRequestFromJson(json);
}

View file

@ -8,29 +8,48 @@ part "room.g.dart";
@freezed
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({
@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 hasFetchedState,
@Default(false) bool hasFetchedMembers,
@Default(IMap.empty()) IMap<String, IMap<String, int>> state,
// required IMap<String, AccountData> accountData,
@Default(IList.empty()) IList<Event> events,
@Default(IMap.empty()) IMap<String, IList<ReadReceipt>> receipts,
@Default(false) bool dismissNotifications,
@Default(true) bool hasMore,
// required IMap<String, AccountData> accountData,
// required IList<Notification> notifications,
}) = _Room;
factory Room.fromJson(Map<String, Object?> json) => _$RoomFromJson(json);
}
@freezed
abstract class TimelineRowTuple with _$TimelineRowTuple {
const factory TimelineRowTuple({
@JsonKey(name: "timeline_rowid") required int timelineRowId,
@JsonKey(name: "event_rowid") int? eventRowId,
}) = _TimelineRowTuple;
factory TimelineRowTuple.fromJson(Map<String, Object?> json) =>
_$TimelineRowTupleFromJson(json);
}

View file

@ -1,9 +1,10 @@
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/key_controller.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/chat_page/sidebar.dart";
import "package:nexus/widgets/chat_page/room_chat.dart";
import "package:nexus/widgets/sidebar.dart";
import "package:nexus/widgets/room_chat.dart";
import "package:nexus/widgets/loading.dart";
class ChatPage extends ConsumerWidget {
@ -15,22 +16,22 @@ class ChatPage extends ConsumerWidget {
final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000;
final initComplete = ref.watch(InitCompleteController.provider);
final roomId = ref.watch(KeyController.provider(KeyController.roomKey));
return Scaffold(
appBar: initComplete ? null : Appbar(),
body: initComplete
? Builder(
builder: (context) => Row(
? Row(
children: [
if (isDesktop) Sidebar(isDesktop: isDesktop),
Expanded(
child: RoomChat(
roomId: roomId,
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
),
),
],
),
)
: Center(
child: Column(

View file

@ -21,7 +21,7 @@ class VerifyPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Enter your recovery key or passphrase below to unlock encrypted messages.\nYour passphrase is usually not the same as your password.",
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
),
SizedBox(height: 12),
FormTextInput(

View file

@ -2,8 +2,10 @@ import "package:color_hash/color_hash.dart";
import "package:cross_cache/cross_cache.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
class AvatarOrHash extends ConsumerWidget {
final Uri? avatar;
@ -28,6 +30,14 @@ class AvatarOrHash extends ConsumerWidget {
color: ColorHash(title).color,
child: Center(child: Text(title.isEmpty ? "" : title[0])),
);
final parsedAvatar = avatar?.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value?.homeserverUrl,
),
) ??
"",
);
return SizedBox(
width: height,
height: height,
@ -42,11 +52,11 @@ class AvatarOrHash extends ConsumerWidget {
child: SizedBox(
width: height,
height: height,
child: avatar == null
child: parsedAvatar == null
? fallback ?? box
: Image(
image: CachedNetworkImage(
avatar.toString(),
parsedAvatar.toString(),
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),

View file

@ -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,
),
);
}

View file

@ -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,
),
),
);
}
}

View file

@ -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(),
),
),
),
],
),
);
}
}

View file

@ -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,
),
),
],
),
);
},
),
),
),
);
}
}

View file

@ -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(),
);
}
}

View file

@ -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),
],
),
),
],
),
),
);
}
}

View file

@ -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(),
);
}
}

View file

@ -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,
);
}
}

View file

@ -1,18 +1,20 @@
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:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/power_level_controller.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/widgets/chat_page/composer/mention_overlay.dart";
import "package:nexus/widgets/chat_page/composer/relation_preview.dart";
import "package:nexus/widgets/chat_page/emoji_picker_button.dart";
import "package:nexus/widgets/composer/mention_overlay.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
class ChatBox extends HookConsumerWidget {
final Message? relatedMessage;
final String roomId;
final Event? relatedEvent;
final RelationType relationType;
final VoidCallback onDismiss;
final FocusNode? node;
@ -22,8 +24,9 @@ class ChatBox extends HookConsumerWidget {
required IList<Tag> tags,
})
onSend;
const ChatBox({
required this.relatedMessage,
const ChatBox(
this.roomId, {
required this.relatedEvent,
required this.relationType,
required this.onDismiss,
required this.onSend,
@ -39,10 +42,8 @@ class ChatBox extends HookConsumerWidget {
final shouldMention = useState(true);
final query = useState("");
if (relationType == RelationType.edit &&
relatedMessage is TextMessage &&
controller.value.text.isEmpty) {
controller.value.text = relatedMessage?.metadata?["editSource"] ?? "";
if (relationType == RelationType.edit && controller.value.text.isEmpty) {
controller.value.text = relatedEvent?.localContent?.editSource ?? "";
}
void send() {
@ -73,7 +74,7 @@ class ChatBox extends HookConsumerWidget {
child: Column(
children: [
RelationPreview(
relatedMessage,
relatedEvent,
shouldMention: shouldMention.value,
toggleShouldMention: () =>
shouldMention.value = !shouldMention.value,
@ -89,7 +90,10 @@ class ChatBox extends HookConsumerWidget {
children:
ref.watch(
PowerLevelController.provider(
PowerLevelConfig(eventType: "m.room.message"),
PowerLevelConfig(
eventType: EventType.message,
roomId: roomId,
),
),
)
? [
@ -126,6 +130,7 @@ class ChatBox extends HookConsumerWidget {
child: FlutterTagger(
triggerStrategy: TriggerStrategy.eager,
overlay: MentionOverlay(
roomId,
query: query.value,
triggerCharacter: triggerCharacter.value,
addTag: ({required id, required name}) {

View file

@ -1,9 +1,12 @@
import "package:flutter/material.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/via_controller.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/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/loading.dart";
@ -11,8 +14,10 @@ import "package:nexus/widgets/loading.dart";
class MentionOverlay extends ConsumerWidget {
final String? triggerCharacter;
final String query;
final String roomId;
final void Function({required String id, required String name}) addTag;
const MentionOverlay({
const MentionOverlay(
this.roomId, {
required this.query,
required this.addTag,
required this.triggerCharacter,
@ -34,7 +39,12 @@ class MentionOverlay extends ConsumerWidget {
"@" =>
ref
.watch(
MembersByTypeController.provider(MembershipStatus.join),
MembersByStatusController.provider(
MembersByStatusConfig(
roomId: roomId,
status: MembershipStatus.join,
),
),
)
.betterWhen(
data: (members) => ListView(
@ -43,33 +53,49 @@ class MentionOverlay extends ConsumerWidget {
? members
: members.where(
(member) =>
member.userId.toLowerCase().contains(
member.stateKey
?.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true ||
member.displayName
.toLowerCase()
switch (member.content) {
MembershipContent(
:final displayName,
) =>
displayName
?.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true,
_ => false,
},
))
.map(
(member) => ListTile(
(member) => switch (member.content) {
MembershipContent(
:final displayName,
:final avatarUrl,
) =>
ListTile(
leading: AvatarOrHash(
member.avatarUrl,
member.displayName,
avatarUrl,
displayName ??
member.stateKey!.localpart,
),
title: Text(member.displayName),
subtitle: Text(member.userId),
title: Text(
displayName ??
member.stateKey!.localpart,
),
subtitle: Text(member.stateKey!),
onTap: () => addTag(
id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})",
name: member.userId
.substring(1)
.split(":")
.first,
id: "[@$displayName](matrix:u/${member.stateKey!.substring(1)})",
name: member.stateKey!.localpart,
),
),
_ => SizedBox.shrink(),
},
)
.toList(),
),

View file

@ -1,19 +1,18 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.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/event_preview.dart";
class RelationPreview extends ConsumerWidget {
final Message? relatedMessage;
final Event? relatedEvent;
final RelationType relationType;
final VoidCallback onDismiss;
final bool shouldMention;
final VoidCallback toggleShouldMention;
const RelationPreview(
this.relatedMessage, {
this.relatedEvent, {
required this.relationType,
required this.onDismiss,
required this.shouldMention,
@ -23,12 +22,12 @@ class RelationPreview extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
if (relatedMessage == null) return SizedBox.shrink();
if (relatedEvent == null) return SizedBox.shrink();
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surfaceContainerHigh,
padding: EdgeInsets.symmetric(horizontal: 8),
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
spacing: 8,
children: [
@ -38,32 +37,10 @@ class RelationPreview extends ConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold),
),
MessageAvatar(relatedMessage!),
Expanded(
child: Row(
spacing: 8,
children: [
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,
),
),
],
child: Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: EventPreview(relatedEvent!),
),
),

View 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),
],
),
),
],
),
),
);
}

View 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)),
),
),
);
}

View 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,
),
);
}

View file

@ -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/mxc_to_https.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/chat_page/expandable_image.dart";
import "package:nexus/widgets/chat_page/html/mention_chip.dart";
import "package:nexus/widgets/chat_page/html/spoiler_text.dart";
import "package:nexus/widgets/chat_page/html/code_block.dart";
import "package:nexus/widgets/chat_page/html/quoted.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/html/mention_chip.dart";
import "package:nexus/widgets/html/spoiler_text.dart";
import "package:nexus/widgets/html/code_block.dart";
import "package:nexus/widgets/html/quoted.dart";
class Html extends ConsumerWidget {
final String html;
final String? roomId;
final TextStyle? textStyle;
const Html(this.html, {this.textStyle, super.key});
const Html(this.html, {this.roomId, this.textStyle, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => HtmlWidget(
html,
buildAsync: false,
textStyle: textStyle,
customWidgetBuilder: (element) {
if (element.attributes.keys.contains("data-mx-profile-fallback")) {
@ -58,13 +60,15 @@ class Html extends ConsumerWidget {
)
: null,
"blockquote" => Quoted(Html(element.innerHtml)),
"blockquote" => Quoted(
Html(element.innerHtml, textStyle: textStyle, roomId: roomId),
),
"a" =>
element.attributes["href"]?.mention == null
? null
: InlineCustomWidget(
child: MentionChip(element.attributes["href"]!),
child: MentionChip(element.attributes["href"]!, roomId),
),
"img" =>

View 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,
),
),
);
}
}

View file

@ -1,32 +1,36 @@
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/author_controller.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/models/event.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MessageAvatar extends ConsumerWidget {
final Message message;
final Event event;
final double height;
const MessageAvatar(this.message, {this.height = 16, super.key});
const MessageAvatar(this.event, {this.height = 24, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(AuthorController.provider(message))
.watch(AuthorController.provider(event))
.betterWhen(
data: (membership) => InkWell(
onTapUp: (details) => context.showUserPopover(
onTapUp: (details) {
context.showUserPopover(
membership,
event.sender,
globalPosition: details.globalPosition,
),
);
},
child: AvatarOrHash(
membership.avatarUrl,
membership.displayName,
membership.displayName ?? event.sender.localpart,
height: height,
),
),
loading: () =>
AvatarOrHash(null, message.authorId.substring(1), height: height),
AvatarOrHash(null, event.sender.localpart, height: height),
);
}

View file

@ -1,16 +1,18 @@
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/author_controller.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/string_to_color.dart";
import "package:nexus/models/event.dart";
class MessageDisplayname extends ConsumerWidget {
final Message message;
final Event event;
final TextStyle? style;
final bool clickable;
const MessageDisplayname(
this.message, {
this.event, {
this.clickable = true,
this.style,
super.key,
@ -18,18 +20,25 @@ class MessageDisplayname extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(AuthorController.provider(message))
.watch(AuthorController.provider(event))
.betterWhen(
data: (membership) => InkWell(
onTapUp: clickable
? (details) => context.showUserPopover(
membership,
event.sender,
globalPosition: details.globalPosition,
)
: null,
child: Text(
"${membership.displayName}${message.metadata?["pmp"] == null ? "" : " (via ${message.authorId})"}",
style: style,
"${membership.displayName ?? event.sender.localpart}${event.pmp == null ? "" : " (via ${event.sender})"}",
style:
style ??
TextStyle(
color: event.sender.colorHash,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),

View 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(),
},
],
),
);
}
}

View 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,
),
],
),
),
),
);
}
}

View 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));
}
}

View 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,
),
};
}
}

View 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),
],
);
}
}

View 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)),
],
),
);
}

View 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(),
};
}
}

View file

@ -1,17 +1,21 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.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/avatar_or_hash.dart";
import "package:nexus/widgets/chat_page/expandable_image.dart";
import "package:nexus/widgets/chat_page/room_menu.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/room_menu.dart";
class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
final bool isDesktop;
final void Function(BuildContext context)? onOpenMemberList;
final void Function(BuildContext context) onOpenDrawer;
final String? roomId;
const RoomAppbar({
required this.roomId,
required this.isDesktop,
required this.onOpenDrawer,
this.onOpenMemberList,
@ -23,13 +27,23 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
@override
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(
leading: isDesktop
? room == null
? null
: ExpandableImage(
room.metadata?.avatar?.toString(),
room.metadata?.avatar
?.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value!.homeserverUrl!,
),
),
)
.toString(),
child: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Rooms",

448
lib/widgets/room_chat.dart Normal file
View 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