Remove flutter chat (#26)

Had to squash merge manually as Forgejo was erroring
This commit is contained in:
Henry Hiles 2026-05-21 16:58:22 -04:00
commit 16cf126df4
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);
debugPrintStack(stackTrace: stackTrace, label: error.toString());
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>> {
class MembersController extends AsyncNotifier<ISet<Event>> {
final String roomId;
MembersController(this.roomId);
@override
Future<IList<Membership>> build() async {
final data = ref.watch(
SelectedRoomController.provider.select(
(value) => value?.metadata == null
? null
: (value!.metadata!.id, value.metadata!.hasMemberList),
),
Future<ISet<Event>> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[roomId]),
);
if (data == null) return const IList.empty();
final state = await ref
.watch(ClientController.provider.notifier)
.getRoomState(
GetRoomStateRequest(
roomId: data.$1,
fetchMembers: data.$2 == false,
includeMembers: true,
),
);
if (room == null) return const ISet.empty();
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();
if (!room.hasFetchedMembers) {
final fetchedState = await ref
.watch(ClientController.provider.notifier)
.getRoomState(
GetRoomStateRequest(
roomId: roomId,
fetchMembers: room.metadata?.hasMemberList ?? true,
includeMembers: true,
),
);
await ref
.read(RoomsController.provider.notifier)
.addState(roomId, fetchedState, isMembers: true);
}
return room.state[EventType.membership.type]?.values
.map((rowId) => room.events[rowId])
.nonNulls
.toISet() ??
const ISet.empty();
}
static final provider =
AsyncNotifierProvider<MembersController, IList<Membership>>(
MembersController.new,
);
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,56 +12,60 @@ 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 room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
);
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 eventRowId = room?.state[EventType.powerLevels.type]?[""];
int powerLevelOf(String userId) => users.containsKey(userId)
? (users[userId] as int)
: (event.content["users_default"] as int? ?? 0);
final event = eventRowId == null ? null : room?.events[eventRowId];
final content = event?.content is PowerLevelsContent
? event!.content
: PowerLevelsContent();
final user = ref.watch(
ClientStateController.provider.select((value) => value?.userId),
);
if (user == null || content is! PowerLevelsContent) return false;
int powerLevelOf(String userId) =>
content.users[userId] ?? content.usersDefault;
final userLevel = powerLevelOf(user);
final 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),
MembershipAction.kick =>
targetLevel != null &&
userLevel >= (event.content["kick"] as int? ?? 50) &&
userLevel > targetLevel,
MembershipActionPowerLevelConfig(:final action, :final targetUser) =>
switch (action) {
MembershipAction.invite => userLevel >= content.invite,
MembershipAction.ban =>
targetLevel != null &&
userLevel >= (event.content["ban"] as int? ?? 50) &&
userLevel > targetLevel,
MembershipAction.kick =>
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
MembershipAction.unban =>
userLevel >= (event.content["ban"] as int? ?? 50),
};
}
MembershipAction.ban =>
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
if (config.eventType == "m.room.redaction") {
return userLevel >= (event.content["redact"] as int? ?? 50);
}
MembershipAction.unban => userLevel >= content.ban,
},
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));
StatePowerLevelConfig(:final eventType) =>
userLevel >= (content.events[eventType.type] ?? content.stateDefault),
return userLevel >= requiredLevel;
RedactionPowerLevelConfig(:final targetUser) =>
userLevel >=
(targetUser == user
? (content.events[EventType.redaction.type] ??
content.eventsDefault)
: content.redact),
};
}
static final provider = NotifierProvider.autoDispose

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 state = await client.getRoomState(
GetRoomStateRequest(roomId: roomId),
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return const IList.empty();
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,
),
);
}
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);
}
final redacts = ref
.read(SelectedRoomController.provider)
?.events
.firstWhere((event) => event.eventId == redactsId);
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;
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
? null
: controller.messages.firstWhereOrNull(
(element) =>
element.metadata?["txnId"] == message.metadata?["txnId"],
);
return oldMessage == null
? controller.insertMessage(message)
: controller.updateMessage(oldMessage, message);
}
Future<void> deleteMessage(Message message, {String? reason}) => ref
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason),
if (!room.hasFetchedState) {
final state = await client.getRoomState(
GetRoomStateRequest(roomId: roomId),
);
Future<bool> loadOlder([InMemoryChatController? chatController]) async {
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
}
// 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();
}
return room.timeline
.toEntryIList(compare: (a, b) => (b?.key ?? 0).compareTo(a?.key ?? 0))
.map((entry) {
if (entry.value == null) return null;
final foundEvent = room.events[entry.value!];
final editedEvent =
foundEvent == null || foundEvent.lastEditRowId == 0
? null
: room.events[foundEvent.lastEditRowId];
return editedEvent == null
? foundEvent
: foundEvent?.copyWith(content: editedEvent.content);
})
.nonNulls
.toIList();
}
Future<void> deleteMessage(Event event, {String? reason}) => ref
.watch(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
roomId: roomId,
reason: reason,
),
);
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),
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
timeline: response.events
.map(
(event) => TimelineRowTuple(
timelineRowId: event.timelineRowId,
eventRowId: event.rowId,
),
)
.toIList(),
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
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
? incoming.timeline
: existing.timeline.updateById(
incoming.timeline,
(item) => item.timelineRowId,
))
.sortedBy((element) => element.timelineRowId)
.toIList(),
reset: false,
hasFetchedMembers:
incoming.hasFetchedMembers || existing.hasFetchedMembers,
hasFetchedState:
incoming.hasFetchedState || existing.hasFetchedState,
timeline: (incoming.reset
? incoming.timeline
: 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?.content case final MembershipContent content) {
return content;
}
final profile = await ref.watch(
ProfileController.provider(config.userId).future,
);
if (member != null) return member;
final profile = await ref.watch(ProfileController.provider(userId).future);
return Membership(
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,23 +25,29 @@ 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) {
addUserId(userId);
if (servers.length >= 5) break;
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") {
addUserId(member?.stateKey);
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,18 +1,24 @@
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(
globalPosition: globalPosition,
children: [
PopupMenuItem(
enabled: false,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: IconTheme(data: IconThemeData(), child: UserPopover(member)),
),
],
);
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, 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(
children: [
if (isDesktop) Sidebar(isDesktop: isDesktop),
Expanded(
child: RoomChat(
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
),
? 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(
query.toLowerCase(),
) ==
true ||
member.displayName
.toLowerCase()
member.stateKey
?.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true,
true ||
switch (member.content) {
MembershipContent(
:final displayName,
) =>
displayName
?.toLowerCase()
.contains(
query.toLowerCase(),
) ==
true,
_ => false,
},
))
.map(
(member) => ListTile(
leading: AvatarOrHash(
member.avatarUrl,
member.displayName,
),
title: Text(member.displayName),
subtitle: Text(member.userId),
onTap: () => addTag(
id: "[@${member.displayName}](matrix:u/${member.userId.substring(1)})",
name: member.userId
.substring(1)
.split(":")
.first,
),
),
(member) => switch (member.content) {
MembershipContent(
:final displayName,
:final avatarUrl,
) =>
ListTile(
leading: AvatarOrHash(
avatarUrl,
displayName ??
member.stateKey!.localpart,
),
title: Text(
displayName ??
member.stateKey!.localpart,
),
subtitle: Text(member.stateKey!),
onTap: () => addTag(
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(
membership,
globalPosition: details.globalPosition,
),
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