parent
dbf30c3706
commit
d37ec2878b
21 changed files with 561 additions and 147 deletions
|
|
@ -114,7 +114,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
|
||||||
- [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
|
- [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
|
||||||
- [x] Notifications page
|
- [x] Notifications page
|
||||||
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
|
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
|
||||||
- [ ] Invites
|
- [x] Invites
|
||||||
- [x] Settings
|
- [x] Settings
|
||||||
|
|
||||||
## Try it out
|
## Try it out
|
||||||
|
|
|
||||||
|
|
@ -191,10 +191,8 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
|
||||||
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
|
||||||
|
|
||||||
Future<void> leaveRoom(Room room) async {
|
Future<void> leaveRoom(String roomId) =>
|
||||||
if (room.metadata == null) return;
|
_sendCommand("leave_room", {"room_id": roomId});
|
||||||
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
|
||||||
Future<List?> getState(GetRoomStateRequest request) async =>
|
Future<List?> getState(GetRoomStateRequest request) async =>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
import "dart:ffi";
|
import "dart:ffi";
|
||||||
|
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/init_complete.dart";
|
import "package:nexus/controllers/init_complete.dart";
|
||||||
|
import "package:nexus/controllers/invites.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/controllers/space_edges.dart";
|
import "package:nexus/controllers/space_edges.dart";
|
||||||
import "package:nexus/controllers/sync_status.dart";
|
import "package:nexus/controllers/sync_status.dart";
|
||||||
|
|
@ -61,10 +63,12 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
final syncData = SyncData.fromJson(decodedMuksEvent);
|
final syncData = SyncData.fromJson(decodedMuksEvent);
|
||||||
final roomProvider = RoomsController.provider;
|
final roomProvider = RoomsController.provider;
|
||||||
final accountDataProvider = AccountDataController.provider;
|
final accountDataProvider = AccountDataController.provider;
|
||||||
|
final invitesProvider = InvitesController.provider;
|
||||||
|
|
||||||
if (syncData.clearState) {
|
if (syncData.clearState) {
|
||||||
ref.invalidate(roomProvider);
|
ref.invalidate(roomProvider);
|
||||||
ref.invalidate(accountDataProvider);
|
ref.invalidate(accountDataProvider);
|
||||||
|
ref.invalidate(invitesProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
|
|
@ -73,6 +77,13 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
ref
|
ref
|
||||||
.read(accountDataProvider.notifier)
|
.read(accountDataProvider.notifier)
|
||||||
.update(syncData.accountData);
|
.update(syncData.accountData);
|
||||||
|
ref
|
||||||
|
.read(invitesProvider.notifier)
|
||||||
|
.update(
|
||||||
|
syncData.invitedRooms,
|
||||||
|
joinedRooms: syncData.rooms.keys.toISet(),
|
||||||
|
leftRooms: syncData.leftRooms,
|
||||||
|
);
|
||||||
|
|
||||||
if (syncData.topLevelSpaces case final topLevelSpaces?) {
|
if (syncData.topLevelSpaces case final topLevelSpaces?) {
|
||||||
ref
|
ref
|
||||||
|
|
|
||||||
117
lib/controllers/invites.dart
Normal file
117
lib/controllers/invites.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
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.dart";
|
||||||
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/models/content/avatar.dart";
|
||||||
|
import "package:nexus/models/content/canonical_alias.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/create.dart";
|
||||||
|
import "package:nexus/models/content/encryption.dart";
|
||||||
|
import "package:nexus/models/content/history_visibility.dart";
|
||||||
|
import "package:nexus/models/content/join_rules.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
import "package:nexus/models/content/name.dart";
|
||||||
|
import "package:nexus/models/content/topic.dart";
|
||||||
|
import "package:nexus/models/invited_room.dart";
|
||||||
|
import "package:nexus/models/room_summary.dart";
|
||||||
|
|
||||||
|
/// Pending invites, newest first.
|
||||||
|
class InvitesController extends Notifier<IList<RoomSummary>> {
|
||||||
|
@override
|
||||||
|
IList<RoomSummary> build() => .new();
|
||||||
|
|
||||||
|
/// Adds new invites, and removes invites for rooms that have since been
|
||||||
|
/// joined or left.
|
||||||
|
void update(
|
||||||
|
IList<InvitedRoom> invitedRooms, {
|
||||||
|
ISet<String> joinedRooms = const .empty(),
|
||||||
|
ISet<String> leftRooms = const .empty(),
|
||||||
|
}) {
|
||||||
|
final userId = ref.read(ClientStateController.provider)?.userId;
|
||||||
|
|
||||||
|
RoomSummary toSummary(InvitedRoom invite) {
|
||||||
|
final summary = invite.inviteState.fold(
|
||||||
|
RoomSummary(roomId: invite.roomId, membership: .invite),
|
||||||
|
(summary, event) => switch (event.content) {
|
||||||
|
NameContent(:final name) when name.isNotEmpty => summary.copyWith(
|
||||||
|
name: name,
|
||||||
|
),
|
||||||
|
AvatarContent(:final url) => summary.copyWith(avatarUrl: url),
|
||||||
|
TopicContent(:final topic) when topic.isNotEmpty => summary.copyWith(
|
||||||
|
topic: topic,
|
||||||
|
),
|
||||||
|
CanonicalAliasContent(:final alias) => summary.copyWith(
|
||||||
|
canonicalAlias: alias,
|
||||||
|
),
|
||||||
|
JoinRulesContent(:final joinRule) => summary.copyWith(
|
||||||
|
joinRule: joinRule,
|
||||||
|
),
|
||||||
|
CreateContent(:final type, :final roomVersion) => summary.copyWith(
|
||||||
|
roomType: type,
|
||||||
|
roomVersion: roomVersion,
|
||||||
|
),
|
||||||
|
EncryptionContent(:final algorithm) => summary.copyWith(
|
||||||
|
encryption: algorithm,
|
||||||
|
),
|
||||||
|
HistoryVisibilityContent(:final historyVisibility) =>
|
||||||
|
summary.copyWith(
|
||||||
|
worldReadable: historyVisibility == .worldReadable,
|
||||||
|
),
|
||||||
|
MembershipContent(:final isDirect) when event.stateKey == userId =>
|
||||||
|
summary.copyWith(invitedBy: event.sender, isDirect: isDirect),
|
||||||
|
_ => summary,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The inviter is only known once the user's own invite has been found
|
||||||
|
final inviterProfile = switch (invite.inviteState
|
||||||
|
.firstWhereOrNull(
|
||||||
|
(event) =>
|
||||||
|
event.type == EventType.membership.type &&
|
||||||
|
event.stateKey == summary.invitedBy,
|
||||||
|
)
|
||||||
|
?.content) {
|
||||||
|
MembershipContent content => content,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return summary.copyWith(
|
||||||
|
inviterProfile: inviterProfile,
|
||||||
|
// Direct messages usually have no name, so use the inviter's
|
||||||
|
name:
|
||||||
|
summary.name ??
|
||||||
|
(summary.isDirect
|
||||||
|
? inviterProfile?.displayName ?? summary.invitedBy?.localpart
|
||||||
|
: null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final newInvites = invitedRooms.map(toSummary).toIList();
|
||||||
|
final newRoomIds = newInvites.map((invite) => invite.roomId).toISet();
|
||||||
|
final removed = joinedRooms.addAll(leftRooms);
|
||||||
|
|
||||||
|
state = newInvites
|
||||||
|
.addAll(state.where((invite) => !newRoomIds.contains(invite.roomId)))
|
||||||
|
.removeWhere((invite) => removed.contains(invite.roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String roomId) =>
|
||||||
|
state = state.removeWhere((invite) => invite.roomId == roomId);
|
||||||
|
|
||||||
|
Future<void> decline(String roomId) async {
|
||||||
|
await ref.read(ClientController.provider.notifier).leaveRoom(roomId);
|
||||||
|
remove(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declines every invite, removing each as it succeeds. If any fail, the
|
||||||
|
/// first error is thrown once all have finished.
|
||||||
|
Future<void> declineAll() =>
|
||||||
|
Future.wait(state.map((invite) => decline(invite.roomId)));
|
||||||
|
|
||||||
|
static final provider =
|
||||||
|
NotifierProvider<InvitesController, IList<RoomSummary>>(
|
||||||
|
InvitesController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import "dart:io";
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/controllers/portal.dart";
|
import "package:nexus/controllers/portal.dart";
|
||||||
|
import "package:nexus/controllers/sidebar_page.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
import "package:flutter_local_notifications/flutter_local_notifications.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
|
@ -35,6 +36,7 @@ class NotificationController
|
||||||
|
|
||||||
if (int.tryParse(details.payload!) case final rowId?) {
|
if (int.tryParse(details.payload!) case final rowId?) {
|
||||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||||
|
ref.read(SidebarPageController.provider.notifier).set(.notifications);
|
||||||
ref
|
ref
|
||||||
.read(NotificationRequestController.provider.notifier)
|
.read(NotificationRequestController.provider.notifier)
|
||||||
.request(rowId);
|
.request(rowId);
|
||||||
|
|
@ -53,6 +55,7 @@ class NotificationController
|
||||||
|
|
||||||
if (int.tryParse(call.arguments) case int rowId) {
|
if (int.tryParse(call.arguments) case int rowId) {
|
||||||
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
||||||
|
ref.read(SidebarPageController.provider.notifier).set(.notifications);
|
||||||
ref
|
ref
|
||||||
.read(NotificationRequestController.provider.notifier)
|
.read(NotificationRequestController.provider.notifier)
|
||||||
.request(rowId);
|
.request(rowId);
|
||||||
|
|
|
||||||
13
lib/controllers/sidebar_page.dart
Normal file
13
lib/controllers/sidebar_page.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/models/sidebar_page.dart";
|
||||||
|
|
||||||
|
class SidebarPageController extends Notifier<SidebarPage> {
|
||||||
|
@override
|
||||||
|
SidebarPage build() => .rooms;
|
||||||
|
|
||||||
|
void set(SidebarPage page) => state = page;
|
||||||
|
|
||||||
|
static final provider = NotifierProvider<SidebarPageController, SidebarPage>(
|
||||||
|
SidebarPageController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
71
lib/helpers/extensions/join_room.dart
Normal file
71
lib/helpers/extensions/join_room.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/controllers/sidebar_page.dart";
|
||||||
|
import "package:nexus/helpers/extensions/focus_room.dart";
|
||||||
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
|
|
||||||
|
extension JoinRoom on BuildContext {
|
||||||
|
/// Joins a room, showing progress, success (with a button to open the room)
|
||||||
|
/// and errors in snackbars.
|
||||||
|
///
|
||||||
|
/// Returns the joined room's ID, or null if joining failed.
|
||||||
|
Future<String?> joinRoom(
|
||||||
|
JoinRoomRequest request, {
|
||||||
|
required String name,
|
||||||
|
}) async {
|
||||||
|
final container = ProviderScope.containerOf(this);
|
||||||
|
final messenger = ScaffoldMessenger.of(this);
|
||||||
|
final colorScheme = Theme.of(this).colorScheme;
|
||||||
|
|
||||||
|
final joining = messenger.showSnackBar(
|
||||||
|
.new(content: Text("Joining $name..."), duration: const .new(days: 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final roomId = await container
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.joinRoom(request);
|
||||||
|
|
||||||
|
joining.close();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
.new(
|
||||||
|
content: Text("Joined $name."),
|
||||||
|
action: .new(
|
||||||
|
label: "Open",
|
||||||
|
onPressed: () async {
|
||||||
|
if (await container.focusRoom(roomId)) {
|
||||||
|
container
|
||||||
|
.read(SidebarPageController.provider.notifier)
|
||||||
|
.set(.rooms);
|
||||||
|
} else {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
.new(
|
||||||
|
content: Text(
|
||||||
|
"$name hasn't finished syncing yet, try again in a moment.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return roomId;
|
||||||
|
} catch (error) {
|
||||||
|
joining.close();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
.new(
|
||||||
|
backgroundColor: colorScheme.errorContainer,
|
||||||
|
content: Text(
|
||||||
|
error.toString(),
|
||||||
|
style: .new(color: colorScheme.onErrorContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ sealed class MembershipContent extends Content with _$MembershipContent {
|
||||||
@JsonKey(name: "membership") required MembershipStatus status,
|
@JsonKey(name: "membership") required MembershipStatus status,
|
||||||
Uri? avatarUrl,
|
Uri? avatarUrl,
|
||||||
String? reason,
|
String? reason,
|
||||||
|
@Default(false) bool isDirect,
|
||||||
}) = _MembershipContent;
|
}) = _MembershipContent;
|
||||||
|
|
||||||
factory MembershipContent.fromJson(Map<String, Object?> json) =>
|
factory MembershipContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
|
||||||
16
lib/models/invited_room.dart
Normal file
16
lib/models/invited_room.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/matrix_event.dart";
|
||||||
|
|
||||||
|
part "invited_room.freezed.dart";
|
||||||
|
part "invited_room.g.dart";
|
||||||
|
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(createToJson: false)
|
||||||
|
class const InvitedRoom({
|
||||||
|
required final String roomId,
|
||||||
|
final IList<MatrixEvent> inviteState = const .empty(),
|
||||||
|
}) with _$InvitedRoom {
|
||||||
|
factory InvitedRoom.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$InvitedRoomFromJson(json);
|
||||||
|
}
|
||||||
28
lib/models/matrix_event.dart
Normal file
28
lib/models/matrix_event.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/epoch_date_time_converter.dart";
|
||||||
|
|
||||||
|
part "matrix_event.freezed.dart";
|
||||||
|
part "matrix_event.g.dart";
|
||||||
|
|
||||||
|
/// A plain Matrix event, as sent by the server (e.g. stripped invite state).
|
||||||
|
///
|
||||||
|
/// Unlike [Event], this isn't stored in the gomuks database, so it has no row
|
||||||
|
/// IDs or local content.
|
||||||
|
@Freezed(toJson: false, fromJson: false)
|
||||||
|
@JsonSerializable(createToJson: false)
|
||||||
|
class const MatrixEvent({
|
||||||
|
required final String type,
|
||||||
|
final String? stateKey,
|
||||||
|
final String? sender,
|
||||||
|
final String? eventId,
|
||||||
|
final String? roomId,
|
||||||
|
@JsonKey(name: "origin_server_ts")
|
||||||
|
@EpochDateTimeConverter()
|
||||||
|
final DateTime? timestamp,
|
||||||
|
required final Content content,
|
||||||
|
}) with _$MatrixEvent {
|
||||||
|
factory MatrixEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MatrixEventFromJson(json)
|
||||||
|
.copyWith(content: .fromEventJson(json["content"], json["type"]));
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
abstract class const NavPage({super.key}) extends Widget {
|
abstract class const NavPage({super.key}) extends Widget {
|
||||||
String get title;
|
String get title;
|
||||||
IconData get icon;
|
IconData get icon;
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
|
||||||
|
int? badgeCount(WidgetRef ref) => null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ part "join_room.g.dart";
|
||||||
class const JoinRoomRequest({
|
class const JoinRoomRequest({
|
||||||
required final String roomIdOrAlias,
|
required final String roomIdOrAlias,
|
||||||
final IList<String> via = const IList.empty(),
|
final IList<String> via = const IList.empty(),
|
||||||
|
|
||||||
|
/// Set when accepting an invite, so gomuks can mark DMs as such.
|
||||||
|
final bool fromInvite = false,
|
||||||
}) with _$JoinRoomRequest {
|
}) with _$JoinRoomRequest {
|
||||||
Map<String, Object?> toJson() => _$JoinRoomRequestToJson(this);
|
Map<String, Object?> toJson() => _$JoinRoomRequestToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/content/create.dart";
|
import "package:nexus/models/content/create.dart";
|
||||||
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/join_rule.dart";
|
import "package:nexus/models/join_rule.dart";
|
||||||
|
import "package:nexus/models/membership_status.dart";
|
||||||
|
|
||||||
part "room_summary.freezed.dart";
|
part "room_summary.freezed.dart";
|
||||||
part "room_summary.g.dart";
|
part "room_summary.g.dart";
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable(createToJson: false)
|
||||||
class const RoomSummary({
|
class const RoomSummary({
|
||||||
required final String roomId,
|
required final String roomId,
|
||||||
@JsonKey(name: "num_joined_members") final int? joinedMembers,
|
@JsonKey(name: "num_joined_members") final int? joinedMembers,
|
||||||
|
|
@ -17,9 +19,24 @@ class const RoomSummary({
|
||||||
final String? topic,
|
final String? topic,
|
||||||
final String? roomVersion,
|
final String? roomVersion,
|
||||||
@JsonKey(unknownEnumValue: RoomType.room) final RoomType? roomType,
|
@JsonKey(unknownEnumValue: RoomType.room) final RoomType? roomType,
|
||||||
}) with _$RoomSummary {
|
|
||||||
Map<String, Object?> toJson() => _$RoomSummaryToJson(this);
|
|
||||||
|
|
||||||
|
/// The encryption algorithm, if the room is encrypted.
|
||||||
|
final String? encryption,
|
||||||
|
final bool? worldReadable,
|
||||||
|
|
||||||
|
/// The current user's membership in the room, if any.
|
||||||
|
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
|
||||||
|
final MembershipStatus? membership,
|
||||||
|
|
||||||
|
/// Who invited the current user, for invites.
|
||||||
|
@JsonKey(includeFromJson: false) final String? invitedBy,
|
||||||
|
|
||||||
|
/// The inviter's membership, for their display name and avatar.
|
||||||
|
@JsonKey(includeFromJson: false) final MembershipContent? inviterProfile,
|
||||||
|
|
||||||
|
/// Whether this is an invite to a direct message.
|
||||||
|
@JsonKey(includeFromJson: false) final bool isDirect = false,
|
||||||
|
}) with _$RoomSummary {
|
||||||
factory RoomSummary.fromJson(Map<String, Object?> json) =>
|
factory RoomSummary.fromJson(Map<String, Object?> json) =>
|
||||||
_$RoomSummaryFromJson(json);
|
_$RoomSummaryFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
lib/models/sidebar_page.dart
Normal file
1
lib/models/sidebar_page.dart
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
enum SidebarPage { rooms, notifications, invites }
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
|
import "package:nexus/models/invited_room.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/models/space_edge.dart";
|
import "package:nexus/models/space_edge.dart";
|
||||||
|
|
||||||
|
|
@ -7,17 +8,16 @@ part "sync_data.freezed.dart";
|
||||||
part "sync_data.g.dart";
|
part "sync_data.g.dart";
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable(createToJson: false)
|
||||||
class const SyncData({
|
class const SyncData({
|
||||||
final bool clearState = false,
|
final bool clearState = false,
|
||||||
final IMap<String, IMap<String, dynamic>> accountData = const IMap.empty(),
|
final IMap<String, IMap<String, dynamic>> accountData = const IMap.empty(),
|
||||||
final IMap<String, Room> rooms = const IMap.empty(),
|
final IMap<String, Room> rooms = const IMap.empty(),
|
||||||
final ISet<String> leftRooms = const ISet.empty(),
|
final ISet<String> leftRooms = const ISet.empty(),
|
||||||
|
final IList<InvitedRoom> invitedRooms = const IList.empty(),
|
||||||
final IMap<String, IList<SpaceEdge>>? spaceEdges,
|
final IMap<String, IList<SpaceEdge>>? spaceEdges,
|
||||||
final IList<String>? topLevelSpaces,
|
final IList<String>? topLevelSpaces,
|
||||||
}) with _$SyncData {
|
}) with _$SyncData {
|
||||||
Map<String, Object?> toJson() => _$SyncDataToJson(this);
|
|
||||||
|
|
||||||
factory SyncData.fromJson(Map<String, Object?> json) =>
|
factory SyncData.fromJson(Map<String, Object?> json) =>
|
||||||
_$SyncDataFromJson(json);
|
_$SyncDataFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/invites.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/models/content/create.dart";
|
||||||
import "package:nexus/models/nav_page.dart";
|
import "package:nexus/models/nav_page.dart";
|
||||||
|
import "package:nexus/models/room_summary.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/room_summary_dialog.dart";
|
||||||
|
|
||||||
class const InvitesList({super.key})
|
class const InvitesList({super.key})
|
||||||
extends StatelessWidget
|
extends HookConsumerWidget
|
||||||
implements NavPage {
|
implements NavPage {
|
||||||
@override
|
@override
|
||||||
String get title => "Invites";
|
String get title => "Invites";
|
||||||
|
|
@ -11,12 +19,117 @@ class const InvitesList({super.key})
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.inbox;
|
IconData get icon => Icons.inbox;
|
||||||
|
|
||||||
// TODO: Show notification badge here
|
|
||||||
@override
|
@override
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
int? badgeCount(WidgetRef ref) => switch (ref.watch(
|
||||||
|
InvitesController.provider.select((invites) => invites.length),
|
||||||
|
)) {
|
||||||
|
0 => null,
|
||||||
|
final count => count,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final invites = ref.watch(InvitesController.provider);
|
||||||
|
final decliningAll = useState(false);
|
||||||
|
|
||||||
|
Future<void> declineAll() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("Decline All Invites"),
|
||||||
|
content: Text(
|
||||||
|
"Are you sure you want to decline all ${invites.length} invites?",
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text("Decline All"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
decliningAll.value = true;
|
||||||
|
await ref
|
||||||
|
.read(InvitesController.provider.notifier)
|
||||||
|
.declineAll()
|
||||||
|
.onError(showError);
|
||||||
|
if (context.mounted) decliningAll.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
floatingActionButton: invites.isEmpty
|
||||||
|
? null
|
||||||
|
: FloatingActionButton.extended(
|
||||||
|
onPressed: decliningAll.value ? null : declineAll,
|
||||||
|
icon: decliningAll.value
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 24,
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.clear_all),
|
||||||
|
label: const Text("Decline all"),
|
||||||
|
),
|
||||||
|
body: invites.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
"No invites",
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView(
|
||||||
|
// Leave room for the FAB under the last invite
|
||||||
|
padding: const .only(left: 8, top: 8, right: 8, bottom: 88),
|
||||||
|
children: invites.map(InviteCard.new).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const InviteCard(final RoomSummary invite, {super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Placeholder();
|
final name = invite.name ?? invite.canonicalAlias ?? invite.roomId;
|
||||||
|
final inviterName =
|
||||||
|
invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
clipBehavior: .antiAlias,
|
||||||
|
child: ListTile(
|
||||||
|
onTap: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => RoomSummaryDialog(invite),
|
||||||
|
),
|
||||||
|
leading: AvatarOrHash(
|
||||||
|
invite.avatarUrl,
|
||||||
|
name,
|
||||||
|
dimension: 40,
|
||||||
|
fallback: invite.isDirect
|
||||||
|
? null
|
||||||
|
: Icon(
|
||||||
|
invite.roomType == RoomType.space
|
||||||
|
? Icons.workspaces
|
||||||
|
: Icons.numbers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(name, maxLines: 1, overflow: .ellipsis),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
if (inviterName != null)
|
||||||
|
"${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName",
|
||||||
|
?invite.topic,
|
||||||
|
].join("\n"),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,8 @@ class const NotificationsList({super.key})
|
||||||
@override
|
@override
|
||||||
IconData get icon => Icons.notifications;
|
IconData get icon => Icons.notifications;
|
||||||
|
|
||||||
// TODO: Show notification badge here
|
|
||||||
@override
|
@override
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
Null badgeCount(_) => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
|
||||||
|
|
||||||
// TODO: Show unread badge here and on sidebar hamburger
|
// TODO: Show unread badge here and on sidebar hamburger
|
||||||
@override
|
@override
|
||||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
int? badgeCount(WidgetRef ref) => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ final class const RoomMenuButton(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await client
|
await client
|
||||||
.leaveRoom(room!)
|
.leaveRoom(room!.metadata!.id)
|
||||||
.onError(showError)
|
.onError(showError)
|
||||||
.whenComplete(snackbar.close);
|
.whenComplete(snackbar.close);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/invites.dart";
|
||||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/join_room.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/expandable_image.dart";
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
import "package:nexus/widgets/linkified_text.dart";
|
import "package:nexus/widgets/linkified_text.dart";
|
||||||
|
|
@ -14,7 +16,12 @@ class const RoomSummaryDialog(
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => AlertDialog(
|
Widget build(BuildContext context) {
|
||||||
|
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
|
||||||
|
final isInvite = summary.membership == .invite;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
constraints: .loose(.fromWidth(400)),
|
constraints: .loose(.fromWidth(400)),
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
contentPadding: EdgeInsets.all(24).copyWith(top: 8),
|
contentPadding: EdgeInsets.all(24).copyWith(top: 8),
|
||||||
|
|
@ -43,6 +50,19 @@ class const RoomSummaryDialog(
|
||||||
),
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (summary.invitedBy case final invitedBy?)
|
||||||
|
ListTile(
|
||||||
|
leading: AvatarOrHash(
|
||||||
|
summary.inviterProfile?.avatarUrl,
|
||||||
|
summary.inviterProfile?.displayName ?? invitedBy.localpart,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
"${summary.isDirect ? "Direct message from" : "Invited by"} ${summary.inviterProfile?.displayName ?? invitedBy.localpart}",
|
||||||
|
),
|
||||||
|
subtitle: SelectableText(invitedBy),
|
||||||
|
),
|
||||||
|
// Invite state never includes the member count
|
||||||
|
if (summary.joinedMembers != null || !isInvite)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"${summary.joinedMembers ?? "Unknown number of"} members",
|
"${summary.joinedMembers ?? "Unknown number of"} members",
|
||||||
|
|
@ -53,7 +73,6 @@ class const RoomSummaryDialog(
|
||||||
title: SelectableText(summary.canonicalAlias ?? summary.roomId),
|
title: SelectableText(summary.canonicalAlias ?? summary.roomId),
|
||||||
leading: Icon(Icons.numbers),
|
leading: Icon(Icons.numbers),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (summary.topic != null)
|
if (summary.topic != null)
|
||||||
ListTile(
|
ListTile(
|
||||||
titleAlignment: ListTileTitleAlignment.titleHeight,
|
titleAlignment: ListTileTitleAlignment.titleHeight,
|
||||||
|
|
@ -63,60 +82,63 @@ class const RoomSummaryDialog(
|
||||||
),
|
),
|
||||||
leading: Icon(Icons.info),
|
leading: Icon(Icons.info),
|
||||||
),
|
),
|
||||||
|
if (summary.encryption != null)
|
||||||
|
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
|
||||||
|
if (summary.worldReadable == true)
|
||||||
|
ListTile(
|
||||||
|
title: Text("Anyone can read the history"),
|
||||||
|
leading: Icon(Icons.public),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||||
if (via != null)
|
if (isInvite) ...[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () {
|
||||||
// Capture everything before the dialog closes.
|
|
||||||
final container = ProviderScope.containerOf(context);
|
final container = ProviderScope.containerOf(context);
|
||||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|
||||||
final roomIdOrAlias = summary.canonicalAlias ?? summary.roomId;
|
container
|
||||||
|
.read(InvitesController.provider.notifier)
|
||||||
|
.decline(summary.roomId)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
|
||||||
|
child: Text("Decline"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final container = ProviderScope.containerOf(context);
|
||||||
|
|
||||||
final snackbar = scaffoldMessenger.showSnackBar(
|
context
|
||||||
.new(
|
.joinRoom(
|
||||||
content: Text("Joining room $roomIdOrAlias."),
|
.new(roomIdOrAlias: summary.roomId, fromInvite: true),
|
||||||
duration: Duration(days: 999),
|
name: name,
|
||||||
),
|
)
|
||||||
);
|
.then((roomId) {
|
||||||
|
if (roomId != null) {
|
||||||
try {
|
container
|
||||||
final id = await container
|
.read(InvitesController.provider.notifier)
|
||||||
.read(ClientController.provider.notifier)
|
.remove(summary.roomId);
|
||||||
.joinRoom(.new(roomIdOrAlias: summary.roomId, via: via!));
|
|
||||||
|
|
||||||
snackbar.close();
|
|
||||||
|
|
||||||
scaffoldMessenger.showSnackBar(
|
|
||||||
.new(
|
|
||||||
content: Text("Room $roomIdOrAlias successfully joined."),
|
|
||||||
action: .new(
|
|
||||||
label: "Open",
|
|
||||||
onPressed: () => container.focusRoom(id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
snackbar.close();
|
|
||||||
scaffoldMessenger.showSnackBar(
|
|
||||||
.new(
|
|
||||||
backgroundColor: colors.errorContainer,
|
|
||||||
content: Text(
|
|
||||||
error.toString(),
|
|
||||||
style: .new(color: colors.onErrorContainer),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text("Accept"),
|
||||||
|
),
|
||||||
|
] else if (via != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.joinRoom(
|
||||||
|
.new(roomIdOrAlias: summary.roomId, via: via!),
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text("Join"),
|
child: Text("Join"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,47 @@
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/controllers/notification_request.dart";
|
import "package:nexus/controllers/sidebar_page.dart";
|
||||||
import "package:nexus/models/nav_page.dart";
|
import "package:nexus/models/nav_page.dart";
|
||||||
|
import "package:nexus/models/sidebar_page.dart";
|
||||||
import "package:nexus/widgets/invites_list.dart";
|
import "package:nexus/widgets/invites_list.dart";
|
||||||
import "package:nexus/widgets/notifications_list.dart";
|
import "package:nexus/widgets/notifications_list.dart";
|
||||||
import "package:nexus/widgets/room_list.dart";
|
import "package:nexus/widgets/room_list.dart";
|
||||||
|
|
||||||
class const Sidebar({super.key}) extends HookConsumerWidget {
|
class const Sidebar({super.key}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
const pages = <NavPage>[RoomList(), NotificationsList(), InvitesList()];
|
final selected = ref.watch(SidebarPageController.provider);
|
||||||
final notificationsIndex = pages.indexWhere(
|
final pages = SidebarPage.values.map<NavPage>(
|
||||||
(page) => page is NotificationsList,
|
(page) => switch (page) {
|
||||||
|
.rooms => const RoomList(),
|
||||||
|
.notifications => const NotificationsList(),
|
||||||
|
.invites => const InvitesList(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final selectedIndex = useState(
|
|
||||||
ref.read(NotificationRequestController.provider) == null
|
|
||||||
? 0
|
|
||||||
: notificationsIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.listen(NotificationRequestController.provider, (_, next) {
|
|
||||||
if (next != null) selectedIndex.value = notificationsIndex;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Drawer(
|
return Drawer(
|
||||||
width: 330,
|
width: 330,
|
||||||
shape: Border(),
|
shape: Border(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: pages[selectedIndex.value],
|
body: pages.elementAt(selected.index),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: selectedIndex.value,
|
selectedIndex: selected.index,
|
||||||
destinations: pages
|
destinations: pages.map((page) {
|
||||||
.map(
|
final badgeCount = page.badgeCount(ref);
|
||||||
(element) => NavigationDestination(
|
|
||||||
icon: Icon(element.icon),
|
return NavigationDestination(
|
||||||
label: element.title,
|
icon: Badge.count(
|
||||||
|
count: badgeCount ?? 0,
|
||||||
|
isLabelVisible: badgeCount != null,
|
||||||
|
child: Icon(page.icon),
|
||||||
),
|
),
|
||||||
)
|
label: page.title,
|
||||||
.toList(),
|
);
|
||||||
onDestinationSelected: (value) => selectedIndex.value = value,
|
}).toList(),
|
||||||
|
onDestinationSelected: (index) => ref
|
||||||
|
.read(SidebarPageController.provider.notifier)
|
||||||
|
.set(SidebarPage.values[index]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue