add invite blocking support plus msc4494 support

To accomplish this I also reworked how account data is stored and added a method to fetch capabilities, and another method to set account data
This commit is contained in:
Henry Hiles 2026-08-04 21:04:55 -04:00
commit f97c5548a3
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
11 changed files with 176 additions and 24 deletions

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "gomuks"] [submodule "gomuks"]
path = gomuks path = gomuks
url = https://github.com/gomuks/gomuks url = https://github.com/gomuks/gomuks
branch = main branch = timedoutuk/versions-rpc

2
gomuks

@ -1 +1 @@
Subproject commit 0f8fb261ab57977fa24a4f63b7aafbb9a4ff1b7b Subproject commit d382c664a04f3f202b86f859d1868fd4654a39e8

View file

@ -1,16 +1,22 @@
import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
class AccountDataController extends Notifier<IMap<String, AccountData>> { class AccountDataController extends Notifier<AccountData> {
@override @override
IMap<String, AccountData> build() => .new(); AccountData build() => .new();
void update(IMap<String, AccountData> newData) => void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
state = .new({...state.unlock, ...newData.unlock}); state = .fromJson({
...json.decode(json.encode(state.toJson())),
...newAccountData
.map((key, value) => MapEntry(key, value["content"]))
.unlock,
});
static final provider = static final provider = NotifierProvider<AccountDataController, AccountData>(
NotifierProvider<AccountDataController, IMap<String, AccountData>>(
AccountDataController.new, AccountDataController.new,
); );
} }

View file

@ -33,10 +33,12 @@ import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/report.dart";
import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_event.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.dart";
import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_membership.dart";
import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/spec_versions_response.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -268,6 +270,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> setMembership(SetMembershipRequest request) => Future<void> setMembership(SetMembershipRequest request) =>
_sendCommand("set_membership", request.toJson()); _sendCommand("set_membership", request.toJson());
Future<void> setAccountData(SetAccountDataRequest request) =>
_sendCommand("set_account_data", request.toJson());
Future<MessageContent> uploadMedia(UploadMediaRequest request) async => Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
.fromJson(await _sendCommand("upload_media", request.toJson())); .fromJson(await _sendCommand("upload_media", request.toJson()));
@ -302,6 +307,9 @@ class ClientController extends AsyncNotifier<int> {
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async => Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
await _sendCommand("oauth_exchange_token", request.toJson()); await _sendCommand("oauth_exchange_token", request.toJson());
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
Future<Uri?> discoverHomeserver(Uri homeserver) async { Future<Uri?> discoverHomeserver(Uri homeserver) async {
try { try {
final response = await _sendCommand("discover_homeserver", { final response = await _sendCommand("discover_homeserver", {

View file

@ -4,9 +4,11 @@ import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.dart"; import "package:m3e_buttons/m3e_buttons.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/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/models/account_data.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
@ -16,6 +18,9 @@ class SettingsSectionsController
@override @override
Future<IMap<String, IList<SettingsCategory>>> build() async { Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future); final settings = await ref.watch(SettingsController.provider.future);
final specVersionsResponse = await ref
.watch(ClientController.provider.notifier)
.getSpecVersions();
return .new({ return .new({
"General": .new([ "General": .new([
@ -93,6 +98,51 @@ class SettingsSectionsController
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true) if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
"Account": .new([ "Account": .new([
.new(title: "Profile", icon: Icons.person, settings: .new([])), .new(title: "Profile", icon: Icons.person, settings: .new([])),
.new(
title: "Safety",
icon: Icons.gpp_good,
settings: .new([
.new(
title: "Invite Blocking",
description:
"Block invites, either completely, or block only invites from users without shared private rooms (depends on server support).",
builder: (title, description, icon) => Consumer(
builder: (context, ref, _) =>
DialogListTile<DefaultInviteAction>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: ref
.watch(AccountDataController.provider)
.invitePermissionConfig
.defaultAction,
options: specVersionsResponse.unstableFeatures.msc4494
? DefaultInviteAction.values
: IList(
DefaultInviteAction.values,
).remove(.denyPublic).toList(),
getName: (option) => switch (option) {
.allow => "Allow",
.deny => "Deny",
.denyPublic => "Deny public",
},
onChanged: (value) => ref
.watch(ClientController.provider.notifier)
.setAccountData(
.new(
type: InvitePermissionConfig.key,
content: InvitePermissionConfig(
defaultAction: value,
),
),
)
.onError(showError),
),
),
icon: Icons.person_off,
),
]),
),
.new( .new(
title: "Other", title: "Other",
icon: Icons.key, icon: Icons.key,

View file

@ -84,9 +84,7 @@ class SpacesController extends Notifier<IList<Space>> {
], ],
}.nonNulls.toISet(); }.nonNulls.toISet();
final directMessages = IMap( final directMessages = accountData.directMessages.values.flattened;
accountData["m.direct"]?.content ?? {},
).values.expand((e) => e).toISet();
final otherRooms = rooms.entries final otherRooms = rooms.entries
.where( .where(

View file

@ -1,16 +1,69 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
part "account_data.freezed.dart"; part "account_data.freezed.dart";
part "account_data.g.dart"; part "account_data.g.dart";
@freezed @freezed
abstract class AccountData with _$AccountData { abstract class AccountData with _$AccountData {
const AccountData._();
static List<dynamic>? readRecentEmojiValue(
Map<dynamic, dynamic> json,
String key,
) => json[key]?["recent_emoji"];
static Map<String, List<dynamic>>? recentEmojiToJson(
IList<RecentEmoji> recentEmoji,
) => {"recent_emoji": recentEmoji.map((emoji) => emoji.toJson()).toList()};
const factory AccountData({ const factory AccountData({
required String userId, @JsonKey(name: InvitePermissionConfig.key)
required String? roomId, @Default(InvitePermissionConfig())
required String type, InvitePermissionConfig invitePermissionConfig,
required dynamic content,
@JsonKey(name: "m.direct")
@Default(IMap.empty())
IMap<String, IList<String>> directMessages,
@JsonKey(
name: "m.recent_emoji",
readValue: AccountData.readRecentEmojiValue,
toJson: AccountData.recentEmojiToJson,
)
@Default(IList.empty())
IList<RecentEmoji> recentEmoji,
}) = _AccountData; }) = _AccountData;
factory AccountData.fromJson(Map<String, Object?> json) => factory AccountData.fromJson(Map<String, Object?> json) =>
_$AccountDataFromJson(json); _$AccountDataFromJson(json);
} }
@freezed
abstract class InvitePermissionConfig with _$InvitePermissionConfig {
static const key = "m.invite_permission_config";
const factory InvitePermissionConfig({
@JsonKey(unknownEnumValue: DefaultInviteAction.allow)
@Default(DefaultInviteAction.allow)
DefaultInviteAction defaultAction,
}) = _InvitePermissionConfig;
factory InvitePermissionConfig.fromJson(Map<String, Object?> json) =>
_$InvitePermissionConfigFromJson(json);
}
@freezed
abstract class RecentEmoji with _$RecentEmoji {
const factory RecentEmoji({required String emoji, required int total}) =
_RecentEmoji;
factory RecentEmoji.fromJson(Map<String, Object?> json) =>
_$RecentEmojiFromJson(json);
}
@JsonEnum(fieldRename: .snake)
enum DefaultInviteAction {
allow,
deny,
@JsonValue("uk.timedout.msc4494.deny_public")
denyPublic,
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "set_account_data.freezed.dart";
part "set_account_data.g.dart";
@freezed
abstract class SetAccountDataRequest with _$SetAccountDataRequest {
const factory SetAccountDataRequest({
required String type,
required dynamic content,
String? roomId,
}) = _SetAccountDataRequest;
factory SetAccountDataRequest.fromJson(Map<String, Object?> json) =>
_$SetAccountDataRequestFromJson(json);
}

View file

@ -0,0 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "spec_versions_response.freezed.dart";
part "spec_versions_response.g.dart";
@freezed
abstract class SpecVersionsResponse with _$SpecVersionsResponse {
const factory SpecVersionsResponse({
required IList<String> versions,
required UnstableFeatures unstableFeatures,
}) = _SpecVersionsResponse;
factory SpecVersionsResponse.fromJson(Map<String, Object?> json) =>
_$SpecVersionsResponseFromJson(json);
}
@freezed
abstract class UnstableFeatures with _$UnstableFeatures {
const factory UnstableFeatures({
@JsonKey(name: "uk.timedout.msc4494") @Default(false) bool msc4494,
}) = _UnstableFeatures;
factory UnstableFeatures.fromJson(Map<String, Object?> json) =>
_$UnstableFeaturesFromJson(json);
}

View file

@ -1,6 +1,5 @@
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/account_data.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";
part "sync_data.freezed.dart"; part "sync_data.freezed.dart";
@ -10,7 +9,7 @@ part "sync_data.g.dart";
abstract class SyncData with _$SyncData { abstract class SyncData with _$SyncData {
const factory SyncData({ const factory SyncData({
@Default(false) bool clearState, @Default(false) bool clearState,
@Default(IMap.empty()) IMap<String, AccountData> accountData, @Default(IMap.empty()) IMap<String, IMap<String, dynamic>> accountData,
@Default(IMap.empty()) IMap<String, Room> rooms, @Default(IMap.empty()) IMap<String, Room> rooms,
@Default(ISet.empty()) ISet<String> leftRooms, @Default(ISet.empty()) ISet<String> leftRooms,
// required IList<InvitedRoom> invitedRooms, // required IList<InvitedRoom> invitedRooms,

View file

@ -214,11 +214,9 @@ class RoomChat extends HookConsumerWidget {
...{ ...{
...ref.watch( ...ref.watch(
AccountDataController.provider.select( AccountDataController.provider.select(
(value) => IList( (value) => value.recentEmoji
value["m.recent_emoji"] .map((entry) => entry.emoji)
?.content["recent_emoji"] ?? .toIList(),
[],
).map((entry) => entry["emoji"]).toIList(),
), ),
), ),
"👍", "👍",