This commit is contained in:
OfficialDakari 2025-11-13 20:07:41 +05:00
commit eed8009808
11 changed files with 185 additions and 86 deletions

View File

@ -1,3 +1,31 @@
## [4.0.0] 13th November 2025
Matrix Dart SDK 4.0.0 comes with support for polls, adds first bits towards OIDC and improved
support for spaces and threads.
This release also fixes a major performance leak while updating user device keys in the sync loop.
Especially for larger accounts this should improve the performance a lot.
v4.0.0 It comes with some breaking changes:
#### Migration guide
- `Client.checkHomeserver()` now returns a fourth value. You can just ignore it if you don't need auth_metadata.
- `RelationshipType.reply` has been removed in favor of `Event.inReplyToEventId()` where you can set if you want to ignore fallbacks or not. This makes it easier to differenciate fallback replies and replies inside of a thread.
#### All changes
- feat: (BREAKING) Discover OIDC auth metadata on Client.checkHomeserver() (Christian Kußowski)
- feat: Allow init with access token (Christian Kußowski)
- feat: Implement msc 3381 polls (krille-chan)
- feat: Use small versions of bullet point characters (Kelrap)
- fix: Correctly remove space child (Christian Kußowski)
- fix: Set join rules with knowk_restricted and multiple allow condition room ids (Christian Kußowski)
- refactor: (BREAKING) Replace Event.relationshipType and Event.relationshipEventId with Event.inReplyToEventId() for replies. (Christian Kußowski)
- refactor: Add option to always call auth metadata (Christian Kußowski)
- refactor: Escape HTML tags before markdown rendering (Christian Kußowski)
- refactor: Make direct chat getter type safe (Christian Kußowski)
- refactor: Simpler update user device keys (Christian Kußowski)
- chore: Cache auth metadata response in client (Christian Kußowski)
- chore: Remove flutter from CI (Christian Kußowski)
## [3.0.2] 24th October 2025 ## [3.0.2] 24th October 2025
- chore: bump vodozemac version to v0.4.0 (Karthikeyan S) - chore: bump vodozemac version to v0.4.0 (Karthikeyan S)

View File

@ -1188,6 +1188,20 @@ class FakeMatrixApi extends BaseClient {
'errcode': 'M_FORBIDDEN', 'errcode': 'M_FORBIDDEN',
'error': 'Blabla', 'error': 'Blabla',
}, },
'/client/v1/auth_metadata': (var req) => {
'authorization_endpoint':
'https://fakeserver.notexisting/oauth2/auth',
'code_challenge_methods_supported': ['S256'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'issuer': 'https://fakeserver.notexisting/',
'registration_endpoint':
'https://fakeserver.notexisting/oauth2/clients/register',
'response_modes_supported': ['query', 'fragment'],
'response_types_supported': ['code'],
'revocation_endpoint':
'https://fakeserver.notexisting/oauth2/revoke',
'token_endpoint': 'https://fakeserver.notexisting/oauth2/token',
},
'/media/v3/preview_url?url=https%3A%2F%2Fmatrix.org&ts=10': (var req) => { '/media/v3/preview_url?url=https%3A%2F%2Fmatrix.org&ts=10': (var req) => {
'og:title': 'Matrix Blog Post', 'og:title': 'Matrix Blog Post',
'og:description': 'This is a really cool blog post from matrix.org', 'og:description': 'This is a really cool blog post from matrix.org',
@ -1338,7 +1352,7 @@ class FakeMatrixApi extends BaseClient {
}, },
}, },
'/client/v3/account/whoami': (var req) => '/client/v3/account/whoami': (var req) =>
{'user_id': 'alice@example.com'}, {'user_id': 'alice@example.com', 'device_id': 'ABCDEFGH'},
'/client/v3/capabilities': (var req) => { '/client/v3/capabilities': (var req) => {
'capabilities': { 'capabilities': {
'm.change_password': {'enabled': false}, 'm.change_password': {'enabled': false},
@ -2721,8 +2735,6 @@ class FakeMatrixApi extends BaseClient {
(var req) => {}, (var req) => {},
'/client/v3/user/%40test%3AfakeServer.notExisting/rooms/!localpart%3Aserver.abc/account_data/m.marked_unread': '/client/v3/user/%40test%3AfakeServer.notExisting/rooms/!localpart%3Aserver.abc/account_data/m.marked_unread':
(var req) => {}, (var req) => {},
'/client/v3/user/%40test%3AfakeServer.notExisting/account_data/m.direct':
(var req) => {},
'/client/v3/user/%40othertest%3AfakeServer.notExisting/account_data/m.direct': '/client/v3/user/%40othertest%3AfakeServer.notExisting/account_data/m.direct':
(var req) => {}, (var req) => {},
'/client/v3/profile/%40alice%3Aexample.com/displayname': (var reqI) => {}, '/client/v3/profile/%40alice%3Aexample.com/displayname': (var reqI) => {},

View File

@ -133,7 +133,7 @@ class Client extends MatrixApi {
@override @override
set homeserver(Uri? homeserver) { set homeserver(Uri? homeserver) {
if (this.homeserver != null && homeserver?.host != this.homeserver?.host) { if (this.homeserver != null && homeserver?.host != this.homeserver?.host) {
_wellKnown = null; _wellKnown = _getAuthMetadataResponseCache = null;
} }
super.homeserver = homeserver; super.homeserver = homeserver;
} }
@ -440,8 +440,13 @@ class Client extends MatrixApi {
return null; return null;
} }
Map<String, dynamic> get directChats => Map<String, List<String>> get directChats =>
_accountData['m.direct']?.content ?? {}; (_accountData['m.direct']?.content ?? {}).map(
(userId, list) => MapEntry(
userId,
(list is! List) ? [] : list.whereType<String>().toList(),
),
);
/// Returns the first room ID from the store (the room with the latest event) /// Returns the first room ID from the store (the room with the latest event)
/// which is a private chat with the user [userId]. /// which is a private chat with the user [userId].
@ -516,9 +521,17 @@ class Client extends MatrixApi {
DiscoveryInformation?, DiscoveryInformation?,
GetVersionsResponse versions, GetVersionsResponse versions,
List<LoginFlow>, List<LoginFlow>,
GetAuthMetadataResponse? authMetadata,
)> checkHomeserver( )> checkHomeserver(
Uri homeserverUrl, { Uri homeserverUrl, {
bool checkWellKnown = true, bool checkWellKnown = true,
/// Weither this method should also call `/auth_metadata` to fetch
/// Matrix native OIDC information. Defaults to if the `/versions` endpoint
/// returns version v1.15 or higher. Set to `true` to always call the
/// endpoint if your homeserver supports the endpoint while not fully
/// supporting version v1.15 yet.
bool? fetchAuthMetadata,
Set<String>? overrideSupportedVersions, Set<String>? overrideSupportedVersions,
}) async { }) async {
final supportedVersions = final supportedVersions =
@ -555,7 +568,21 @@ class Client extends MatrixApi {
); );
} }
return (wellKnown, versions, loginTypes); fetchAuthMetadata ??= versions.versions.any(
(v) => isVersionGreaterThanOrEqualTo(v, 'v1.15'),
);
GetAuthMetadataResponse? authMetadata;
if (fetchAuthMetadata) {
try {
authMetadata = await getAuthMetadata();
} on MatrixException catch (e, s) {
if (e.error != MatrixError.M_UNRECOGNIZED) {
Logs().w('Unable to discover OIDC auth metadata.', e, s);
}
}
}
return (wellKnown, versions, loginTypes, authMetadata);
} catch (_) { } catch (_) {
homeserver = null; homeserver = null;
rethrow; rethrow;
@ -720,6 +747,12 @@ class Client extends MatrixApi {
return response; return response;
} }
GetAuthMetadataResponse? _getAuthMetadataResponseCache;
@override
Future<GetAuthMetadataResponse> getAuthMetadata() async =>
_getAuthMetadataResponseCache ??= await super.getAuthMetadata();
/// Sends a logout command to the homeserver and clears all local data, /// Sends a logout command to the homeserver and clears all local data,
/// including all persistent data from the store. /// including all persistent data from the store.
@override @override
@ -1961,9 +1994,9 @@ class Client extends MatrixApi {
/// ///
/// Sends [LoginState.loggedIn] to [onLoginStateChanged]. /// Sends [LoginState.loggedIn] to [onLoginStateChanged].
/// ///
/// If one of [newToken], [newUserID], [newDeviceID], [newDeviceName] is set then /// If one of [newToken] is set, but one of [newUserID], [newDeviceID] is
/// all of them must be set! If you don't set them, this method will try to /// null, then this method calls `/whoami` to fetch user ID and device ID
/// get them from the database. /// and rethrows if this request fails.
/// ///
/// Set [waitForFirstSync] and [waitUntilLoadCompletedLoaded] to false to speed this /// Set [waitForFirstSync] and [waitUntilLoadCompletedLoaded] to false to speed this
/// up. You can then wait for `roomsLoading`, `_accountDataLoading` and /// up. You can then wait for `roomsLoading`, `_accountDataLoading` and
@ -1987,16 +2020,9 @@ class Client extends MatrixApi {
/// To track what actually happens you can set a callback here. /// To track what actually happens you can set a callback here.
void Function(InitState)? onInitStateChanged, void Function(InitState)? onInitStateChanged,
}) async { }) async {
if ((newToken != null || if (newToken != null && homeserver == null && newHomeserver == null) {
newUserID != null ||
newDeviceID != null ||
newDeviceName != null) &&
(newToken == null ||
newUserID == null ||
newDeviceID == null ||
newDeviceName == null)) {
throw ClientInitPreconditionError( throw ClientInitPreconditionError(
'If one of [newToken, newUserID, newDeviceID, newDeviceName] is set then all of them must be set!', 'init() can not be performed with an access token when no homeserver was specified.',
); );
} }
@ -2088,6 +2114,12 @@ class Client extends MatrixApi {
return; return;
} }
if (accessToken != null && (userID == null || deviceID == null)) {
final userInfo = await getTokenOwner();
_userID = userID = userInfo.userId;
_deviceID = userInfo.deviceId;
}
if (accessToken == null || homeserver == null || userID == null) { if (accessToken == null || homeserver == null || userID == null) {
if (legacyDatabaseBuilder != null) { if (legacyDatabaseBuilder != null) {
await _migrateFromLegacyDatabase( await _migrateFromLegacyDatabase(

View File

@ -32,7 +32,6 @@ import 'package:matrix/src/utils/markdown.dart';
import 'package:matrix/src/utils/multipart_request_progress.dart'; import 'package:matrix/src/utils/multipart_request_progress.dart';
abstract class RelationshipTypes { abstract class RelationshipTypes {
static const String reply = 'm.in_reply_to';
static const String edit = 'm.replace'; static const String edit = 'm.replace';
static const String reaction = 'm.annotation'; static const String reaction = 'm.annotation';
static const String reference = 'm.reference'; static const String reference = 'm.reference';
@ -490,31 +489,13 @@ class Event extends MatrixEvent {
/// event fallback if the relationship type is `m.thread`. /// event fallback if the relationship type is `m.thread`.
/// https://spec.matrix.org/v1.14/client-server-api/#fallback-for-unthreaded-clients /// https://spec.matrix.org/v1.14/client-server-api/#fallback-for-unthreaded-clients
Future<Event?> getReplyEvent(Timeline timeline) async { Future<Event?> getReplyEvent(Timeline timeline) async {
switch (relationshipType) { final relationshipEventId = content
case RelationshipTypes.reply: .tryGetMap<String, Object?>('m.relates_to')
final relationshipEventId = this.relationshipEventId; ?.tryGetMap<String, Object?>('m.in_reply_to')
return relationshipEventId == null
? null
: await timeline.getEventById(relationshipEventId);
case RelationshipTypes.thread:
final relationshipContent =
content.tryGetMap<String, Object?>('m.relates_to');
if (relationshipContent == null) return null;
final String? relationshipEventId;
if (relationshipContent.tryGet<bool>('is_falling_back') == true) {
relationshipEventId = relationshipContent
.tryGetMap<String, Object?>('m.in_reply_to')
?.tryGet<String>('event_id'); ?.tryGet<String>('event_id');
} else {
relationshipEventId = this.relationshipEventId;
}
return relationshipEventId == null return relationshipEventId == null
? null ? null
: await timeline.getEventById(relationshipEventId); : await timeline.getEventById(relationshipEventId);
default:
return null;
}
} }
/// If this event is encrypted and the decryption was not successful because /// If this event is encrypted and the decryption was not successful because
@ -1021,28 +1002,28 @@ class Event extends MatrixEvent {
return transactionId == search; return transactionId == search;
} }
/// Get the relationship type of an event. `null` if there is none /// Get the relationship type of an event. `null` if there is none.
String? get relationshipType { String? get relationshipType => content
final mRelatesTo = content.tryGetMap<String, Object?>('m.relates_to'); .tryGetMap<String, Object?>('m.relates_to')
if (mRelatesTo == null) { ?.tryGet<String>('rel_type');
return null;
}
final relType = mRelatesTo.tryGet<String>('rel_type');
if (relType == RelationshipTypes.thread) {
return RelationshipTypes.thread;
}
if (mRelatesTo.containsKey('m.in_reply_to')) { /// Get the event ID that this relationship will reference and `null` if there
return RelationshipTypes.reply; /// is none. This could for example be the thread root, the original event for
} /// an edit or the event, this is an reaction for. For replies please use
return relType; /// `Event.inReplyToEventId()` instead!
} String? get relationshipEventId => content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('event_id');
/// Get the event ID that this relationship will reference. `null` if there is none /// If this event is in reply to another event, this returns the event ID or
String? get relationshipEventId { /// null if this event is not a reply.
final relatesToMap = content.tryGetMap<String, Object?>('m.relates_to'); String? inReplyToEventId({bool includingFallback = true}) {
return relatesToMap?.tryGet<String>('event_id') ?? final isFallback = content
relatesToMap .tryGetMap<String, Object?>('m.relates_to')
?.tryGet<bool>('is_falling_back');
if (isFallback == true && !includingFallback) return null;
return content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGetMap<String, Object?>('m.in_reply_to') ?.tryGetMap<String, Object?>('m.in_reply_to')
?.tryGet<String>('event_id'); ?.tryGet<String>('event_id');
} }

View File

@ -436,7 +436,7 @@ class Room {
final cache = _cachedDirectChatMatrixId; final cache = _cachedDirectChatMatrixId;
if (cache != null) { if (cache != null) {
final roomIds = client.directChats[cache]; final roomIds = client.directChats[cache];
if (roomIds is List && roomIds.contains(id)) { if (roomIds != null && roomIds.contains(id)) {
return cache; return cache;
} }
} }
@ -1608,21 +1608,21 @@ class Room {
/// Sets this room as a direct chat for this user if not already. /// Sets this room as a direct chat for this user if not already.
Future<void> addToDirectChat(String userID) async { Future<void> addToDirectChat(String userID) async {
final directChats = client.directChats; final dmRooms = List<String>.from(client.directChats[userID] ?? []);
if (directChats[userID] is List) { if (dmRooms.contains(id)) {
if (!directChats[userID].contains(id)) { Logs().d('Already a direct chat.');
directChats[userID].add(id);
} else {
return; return;
} // Is already in direct chats
} else {
directChats[userID] = [id];
} }
dmRooms.add(id);
await client.setAccountData( await client.setAccountData(
client.userID!, client.userID!,
'm.direct', 'm.direct',
directChats, {
...client.directChats,
userID: dmRooms,
},
); );
return; return;
} }

View File

@ -212,7 +212,13 @@ String markdown(
bool convertLinebreaks = true, bool convertLinebreaks = true,
}) { }) {
var ret = markdownToHtml( var ret = markdownToHtml(
text.replaceNewlines(), text
.replaceAllMapped(
// Replace HTML tags
RegExp(r'<([^>]*)>'),
(match) => '&lt;${match.group(1)}&gt;',
)
.replaceNewlines(),
extensionSet: ExtensionSet.gitHubFlavored, extensionSet: ExtensionSet.gitHubFlavored,
blockSyntaxes: [ blockSyntaxes: [
BlockLatexSyntax(), BlockLatexSyntax(),

View File

@ -1,6 +1,6 @@
name: matrix name: matrix
description: Matrix Dart SDK description: Matrix Dart SDK
version: 3.0.2 version: 4.0.0
homepage: https://famedly.com homepage: https://famedly.com
repository: https://github.com/famedly/matrix-dart-sdk.git repository: https://github.com/famedly/matrix-dart-sdk.git
issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues

View File

@ -524,6 +524,22 @@ void main() {
FakeMatrixApi.currentApi?.api = oldapi!; FakeMatrixApi.currentApi?.api = oldapi!;
}); });
test('init() with access token', () async {
final client = Client(
'testclient',
httpClient: FakeMatrixApi(),
database: await getDatabase(),
);
await client.init(
newToken: 'abcd1234',
newHomeserver: Uri.parse('https://fakeserver.notexisting'),
);
expect(client.isLogged(), true);
expect(client.userID, 'alice@example.com');
expect(client.deviceID, 'ABCDEFGH');
await client.dispose();
});
test('Login', () async { test('Login', () async {
matrix = Client( matrix = Client(
'testclient', 'testclient',
@ -531,9 +547,14 @@ void main() {
database: await getDatabase(), database: await getDatabase(),
); );
await matrix.checkHomeserver( final (_, _, _, authMetadata) = await matrix.checkHomeserver(
Uri.parse('https://fakeserver.notexisting'), Uri.parse('https://fakeserver.notexisting'),
checkWellKnown: false, checkWellKnown: false,
fetchAuthMetadata: true,
);
expect(
authMetadata?.issuer.toString(),
'https://fakeserver.notexisting/',
); );
final loginResp = await matrix.login( final loginResp = await matrix.login(

View File

@ -79,7 +79,7 @@ void main() async {
expect(event.formattedText, formatted_body); expect(event.formattedText, formatted_body);
expect(event.body, body); expect(event.body, body);
expect(event.type, EventTypes.Message); expect(event.type, EventTypes.Message);
expect(event.relationshipType, RelationshipTypes.reply); expect(event.inReplyToEventId(), '\$1234:example.com');
jsonObj['state_key'] = ''; jsonObj['state_key'] = '';
final state = Event.fromJson(jsonObj, room); final state = Event.fromJson(jsonObj, room);
expect(state.eventId, id); expect(state.eventId, id);
@ -178,8 +178,8 @@ void main() async {
}; };
event = Event.fromJson(jsonObj, room); event = Event.fromJson(jsonObj, room);
expect(event.messageType, MessageTypes.Text); expect(event.messageType, MessageTypes.Text);
expect(event.relationshipType, RelationshipTypes.reply); expect(event.inReplyToEventId(), '1234');
expect(event.relationshipEventId, '1234'); expect(event.relationshipEventId, null);
}); });
test('relationship types', () async { test('relationship types', () async {
@ -212,8 +212,22 @@ void main() async {
}, },
}; };
event = Event.fromJson(jsonObj, room); event = Event.fromJson(jsonObj, room);
expect(event.relationshipType, RelationshipTypes.reply); expect(event.inReplyToEventId(), 'def');
expect(event.relationshipEventId, 'def'); expect(event.relationshipEventId, null);
jsonObj['content']['m.relates_to'] = {
'rel_type': 'm.thread',
'event_id': '\$root',
'm.in_reply_to': {
'event_id': '\$target',
},
'is_falling_back': true,
};
event = Event.fromJson(jsonObj, room);
expect(event.relationshipType, RelationshipTypes.thread);
expect(event.inReplyToEventId(), '\$target');
expect(event.inReplyToEventId(includingFallback: false), null);
expect(event.relationshipEventId, '\$root');
}); });
test('redact', () async { test('redact', () async {

View File

@ -220,6 +220,10 @@ void main() {
), ),
'<p>The first<br/>codeblock</p><pre><code class="language-dart">void main(){\nprint(something);\n}\n</code></pre><p>And the second code block</p><pre><code class="language-js">meow\nmeow\n</code></pre>', '<p>The first<br/>codeblock</p><pre><code class="language-dart">void main(){\nprint(something);\n}\n</code></pre><p>And the second code block</p><pre><code class="language-js">meow\nmeow\n</code></pre>',
); );
expect(
markdown('Test <m> *unescaped*'),
'Test &lt;m&gt; <em>unescaped</em>',
);
}); });
test('Checkboxes', () { test('Checkboxes', () {
expect( expect(

View File

@ -139,6 +139,7 @@ void main() async {
await user1.setPower(50); await user1.setPower(50);
}); });
test('startDirectChat', () async { test('startDirectChat', () async {
FakeMatrixApi.client = user1.room.client;
await user1.startDirectChat(waitForSync: false); await user1.startDirectChat(waitForSync: false);
}); });
test('getPresence', () async { test('getPresence', () async {