From c9f8ece8d4e572083561f33b244b726523a57d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Tue, 4 Nov 2025 14:57:30 +0100 Subject: [PATCH 1/8] refactor: Escape HTML tags before markdown rendering --- lib/src/utils/markdown.dart | 8 +++++++- test/markdown_test.dart | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/src/utils/markdown.dart b/lib/src/utils/markdown.dart index bae521d5..2a4c1437 100644 --- a/lib/src/utils/markdown.dart +++ b/lib/src/utils/markdown.dart @@ -212,7 +212,13 @@ String markdown( bool convertLinebreaks = true, }) { var ret = markdownToHtml( - text.replaceNewlines(), + text + .replaceAllMapped( + // Replace HTML tags + RegExp(r'<([^>]*)>'), + (match) => '<${match.group(1)}>', + ) + .replaceNewlines(), extensionSet: ExtensionSet.gitHubFlavored, blockSyntaxes: [ BlockLatexSyntax(), diff --git a/test/markdown_test.dart b/test/markdown_test.dart index ac3ae2f0..7b946fef 100644 --- a/test/markdown_test.dart +++ b/test/markdown_test.dart @@ -220,6 +220,10 @@ void main() { ), '

The first
codeblock

void main(){\nprint(something);\n}\n

And the second code block

meow\nmeow\n
', ); + expect( + markdown('Test *unescaped*'), + 'Test <m> unescaped', + ); }); test('Checkboxes', () { expect( From 374c8c537951c3a9f1933a37f708ca8b3521e1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Tue, 4 Nov 2025 13:19:36 +0100 Subject: [PATCH 2/8] feat: (BREAKING) Discover OIDC auth metadata on Client.checkHomeserver() --- lib/src/client.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index 20f81c95..c84c2264 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -515,6 +515,7 @@ class Client extends MatrixApi { DiscoveryInformation?, GetVersionsResponse versions, List, + GetAuthMetadataResponse? authMetadata, )> checkHomeserver( Uri homeserverUrl, { bool checkWellKnown = true, @@ -554,7 +555,20 @@ class Client extends MatrixApi { ); } - return (wellKnown, versions, loginTypes); + GetAuthMetadataResponse? authMetadata; + if (versions.versions.any( + (v) => isVersionGreaterThanOrEqualTo(v, 'v1.16'), + )) { + 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 (_) { homeserver = null; rethrow; From c30c1c15a1d73bd4a3dffe6f194ca1e89829889f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 9 Nov 2025 08:41:20 +0100 Subject: [PATCH 3/8] refactor: Add option to always call auth metadata This also makes sure we call on v1.15 as there the endpoint was actually introduced. Also adds a unit test. --- lib/fake_matrix_api.dart | 14 ++++++++++++++ lib/src/client.dart | 14 +++++++++++--- test/client_test.dart | 7 ++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index e7f144bb..ed273074 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -1188,6 +1188,20 @@ class FakeMatrixApi extends BaseClient { 'errcode': 'M_FORBIDDEN', '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) => { 'og:title': 'Matrix Blog Post', 'og:description': 'This is a really cool blog post from matrix.org', diff --git a/lib/src/client.dart b/lib/src/client.dart index c84c2264..ba169029 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -519,6 +519,13 @@ class Client extends MatrixApi { )> checkHomeserver( Uri homeserverUrl, { 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? overrideSupportedVersions, }) async { final supportedVersions = @@ -555,10 +562,11 @@ class Client extends MatrixApi { ); } + fetchAuthMetadata ??= versions.versions.any( + (v) => isVersionGreaterThanOrEqualTo(v, 'v1.15'), + ); GetAuthMetadataResponse? authMetadata; - if (versions.versions.any( - (v) => isVersionGreaterThanOrEqualTo(v, 'v1.16'), - )) { + if (fetchAuthMetadata) { try { authMetadata = await getAuthMetadata(); } on MatrixException catch (e, s) { diff --git a/test/client_test.dart b/test/client_test.dart index 2db2b1d9..22981ce1 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -531,9 +531,14 @@ void main() { database: await getDatabase(), ); - await matrix.checkHomeserver( + final (_, _, _, authMetadata) = await matrix.checkHomeserver( Uri.parse('https://fakeserver.notexisting'), checkWellKnown: false, + fetchAuthMetadata: true, + ); + expect( + authMetadata?.issuer.toString(), + 'https://fakeserver.notexisting/', ); final loginResp = await matrix.login( From 4170c0fca5717f1923075e81d12a9695ee7645f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 9 Nov 2025 09:55:22 +0100 Subject: [PATCH 4/8] feat: Allow init with access token Makes it possible to just init with an access token and a homeserver uri and the init() method would just call /whoami to fetch the missing data. Makes it easier to login with Matrix native OIDC --- lib/fake_matrix_api.dart | 2 +- lib/src/client.dart | 23 +++++++++++------------ test/client_test.dart | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index ed273074..8a3d6a11 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -1352,7 +1352,7 @@ class FakeMatrixApi extends BaseClient { }, }, '/client/v3/account/whoami': (var req) => - {'user_id': 'alice@example.com'}, + {'user_id': 'alice@example.com', 'device_id': 'ABCDEFGH'}, '/client/v3/capabilities': (var req) => { 'capabilities': { 'm.change_password': {'enabled': false}, diff --git a/lib/src/client.dart b/lib/src/client.dart index ba169029..de0fb683 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1982,9 +1982,9 @@ class Client extends MatrixApi { /// /// Sends [LoginState.loggedIn] to [onLoginStateChanged]. /// - /// If one of [newToken], [newUserID], [newDeviceID], [newDeviceName] is set then - /// all of them must be set! If you don't set them, this method will try to - /// get them from the database. + /// If one of [newToken] is set, but one of [newUserID], [newDeviceID] is + /// null, then this method calls `/whoami` to fetch user ID and device ID + /// and rethrows if this request fails. /// /// Set [waitForFirstSync] and [waitUntilLoadCompletedLoaded] to false to speed this /// up. You can then wait for `roomsLoading`, `_accountDataLoading` and @@ -2008,16 +2008,9 @@ class Client extends MatrixApi { /// To track what actually happens you can set a callback here. void Function(InitState)? onInitStateChanged, }) async { - if ((newToken != null || - newUserID != null || - newDeviceID != null || - newDeviceName != null) && - (newToken == null || - newUserID == null || - newDeviceID == null || - newDeviceName == null)) { + if (newToken != null && homeserver == null && newHomeserver == null) { 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.', ); } @@ -2109,6 +2102,12 @@ class Client extends MatrixApi { 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 (legacyDatabaseBuilder != null) { await _migrateFromLegacyDatabase( diff --git a/test/client_test.dart b/test/client_test.dart index 22981ce1..514ffe3f 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -524,6 +524,22 @@ void main() { 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 { matrix = Client( 'testclient', From 4e3e7d9ccc950c5fb041e6a5f7383264346eacd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sun, 9 Nov 2025 13:38:00 +0100 Subject: [PATCH 5/8] chore: Cache auth metadata response in client This is helpful for oidc and a requirement for implementing soft logout with oidc. --- lib/src/client.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/client.dart b/lib/src/client.dart index de0fb683..cd88d55c 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -132,7 +132,7 @@ class Client extends MatrixApi { @override set homeserver(Uri? homeserver) { if (this.homeserver != null && homeserver?.host != this.homeserver?.host) { - _wellKnown = null; + _wellKnown = _getAuthMetadataResponseCache = null; } super.homeserver = homeserver; } @@ -741,6 +741,12 @@ class Client extends MatrixApi { return response; } + GetAuthMetadataResponse? _getAuthMetadataResponseCache; + + @override + Future getAuthMetadata() async => + _getAuthMetadataResponseCache ??= await super.getAuthMetadata(); + /// Sends a logout command to the homeserver and clears all local data, /// including all persistent data from the store. @override From 4a4ccfd4e8d2e18385ca790b42d751c02e2f20a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Tue, 11 Nov 2025 09:16:30 +0100 Subject: [PATCH 6/8] refactor: Make direct chat getter type safe This also makes sure that we do not accidentally change it in RAM before the change comes from the server when calling addToDirectChat() --- lib/fake_matrix_api.dart | 2 -- lib/src/client.dart | 9 +++++++-- lib/src/room.dart | 22 +++++++++++----------- test/user_test.dart | 1 + 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/fake_matrix_api.dart b/lib/fake_matrix_api.dart index 8a3d6a11..0cf2c8ec 100644 --- a/lib/fake_matrix_api.dart +++ b/lib/fake_matrix_api.dart @@ -2735,8 +2735,6 @@ class FakeMatrixApi extends BaseClient { (var req) => {}, '/client/v3/user/%40test%3AfakeServer.notExisting/rooms/!localpart%3Aserver.abc/account_data/m.marked_unread': (var req) => {}, - '/client/v3/user/%40test%3AfakeServer.notExisting/account_data/m.direct': - (var req) => {}, '/client/v3/user/%40othertest%3AfakeServer.notExisting/account_data/m.direct': (var req) => {}, '/client/v3/profile/%40alice%3Aexample.com/displayname': (var reqI) => {}, diff --git a/lib/src/client.dart b/lib/src/client.dart index cd88d55c..d221e1fe 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -439,8 +439,13 @@ class Client extends MatrixApi { return null; } - Map get directChats => - _accountData['m.direct']?.content ?? {}; + Map> get directChats => + (_accountData['m.direct']?.content ?? {}).map( + (userId, list) => MapEntry( + userId, + (list is! List) ? [] : list.whereType().toList(), + ), + ); /// Returns the first room ID from the store (the room with the latest event) /// which is a private chat with the user [userId]. diff --git a/lib/src/room.dart b/lib/src/room.dart index da2fd10e..dc32f842 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -349,7 +349,7 @@ class Room { final cache = _cachedDirectChatMatrixId; if (cache != null) { final roomIds = client.directChats[cache]; - if (roomIds is List && roomIds.contains(id)) { + if (roomIds != null && roomIds.contains(id)) { return cache; } } @@ -1519,21 +1519,21 @@ class Room { /// Sets this room as a direct chat for this user if not already. Future addToDirectChat(String userID) async { - final directChats = client.directChats; - if (directChats[userID] is List) { - if (!directChats[userID].contains(id)) { - directChats[userID].add(id); - } else { - return; - } // Is already in direct chats - } else { - directChats[userID] = [id]; + final dmRooms = List.from(client.directChats[userID] ?? []); + if (dmRooms.contains(id)) { + Logs().d('Already a direct chat.'); + return; } + dmRooms.add(id); + await client.setAccountData( client.userID!, 'm.direct', - directChats, + { + ...client.directChats, + userID: dmRooms, + }, ); return; } diff --git a/test/user_test.dart b/test/user_test.dart index f732013f..89065bfd 100644 --- a/test/user_test.dart +++ b/test/user_test.dart @@ -139,6 +139,7 @@ void main() async { await user1.setPower(50); }); test('startDirectChat', () async { + FakeMatrixApi.client = user1.room.client; await user1.startDirectChat(waitForSync: false); }); test('getPresence', () async { From 1365cbffe5c39c3fa8131373acba018643aa18cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 5 Nov 2025 08:50:51 +0100 Subject: [PATCH 7/8] refactor: (BREAKING) Replace Event.relationshipType and Event.relationshipEventId with Event.inReplyToEventId() for replies. This fixes the situation that an event can be a reply and in a thread. Before we have seen reply as an relationshipType but this does conflict with the concept of threads, where an event can be of relationship type "thread" but also be a reply with being a fallback or not. --- lib/src/event.dart | 77 +++++++++++++++++--------------------------- test/event_test.dart | 24 +++++++++++--- 2 files changed, 48 insertions(+), 53 deletions(-) diff --git a/lib/src/event.dart b/lib/src/event.dart index cbe6548a..4317855a 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -32,7 +32,6 @@ import 'package:matrix/src/utils/markdown.dart'; import 'package:matrix/src/utils/multipart_request_progress.dart'; abstract class RelationshipTypes { - static const String reply = 'm.in_reply_to'; static const String edit = 'm.replace'; static const String reaction = 'm.annotation'; static const String reference = 'm.reference'; @@ -490,31 +489,13 @@ class Event extends MatrixEvent { /// event fallback if the relationship type is `m.thread`. /// https://spec.matrix.org/v1.14/client-server-api/#fallback-for-unthreaded-clients Future getReplyEvent(Timeline timeline) async { - switch (relationshipType) { - case RelationshipTypes.reply: - final relationshipEventId = this.relationshipEventId; - return relationshipEventId == null - ? null - : await timeline.getEventById(relationshipEventId); - - case RelationshipTypes.thread: - final relationshipContent = - content.tryGetMap('m.relates_to'); - if (relationshipContent == null) return null; - final String? relationshipEventId; - if (relationshipContent.tryGet('is_falling_back') == true) { - relationshipEventId = relationshipContent - .tryGetMap('m.in_reply_to') - ?.tryGet('event_id'); - } else { - relationshipEventId = this.relationshipEventId; - } - return relationshipEventId == null - ? null - : await timeline.getEventById(relationshipEventId); - default: - return null; - } + final relationshipEventId = content + .tryGetMap('m.relates_to') + ?.tryGetMap('m.in_reply_to') + ?.tryGet('event_id'); + return relationshipEventId == null + ? null + : await timeline.getEventById(relationshipEventId); } /// If this event is encrypted and the decryption was not successful because @@ -1021,30 +1002,30 @@ class Event extends MatrixEvent { return transactionId == search; } - /// Get the relationship type of an event. `null` if there is none - String? get relationshipType { - final mRelatesTo = content.tryGetMap('m.relates_to'); - if (mRelatesTo == null) { - return null; - } - final relType = mRelatesTo.tryGet('rel_type'); - if (relType == RelationshipTypes.thread) { - return RelationshipTypes.thread; - } + /// Get the relationship type of an event. `null` if there is none. + String? get relationshipType => content + .tryGetMap('m.relates_to') + ?.tryGet('rel_type'); - if (mRelatesTo.containsKey('m.in_reply_to')) { - return RelationshipTypes.reply; - } - return relType; - } + /// Get the event ID that this relationship will reference and `null` if there + /// 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 + /// `Event.inReplyToEventId()` instead! + String? get relationshipEventId => content + .tryGetMap('m.relates_to') + ?.tryGet('event_id'); - /// Get the event ID that this relationship will reference. `null` if there is none - String? get relationshipEventId { - final relatesToMap = content.tryGetMap('m.relates_to'); - return relatesToMap?.tryGet('event_id') ?? - relatesToMap - ?.tryGetMap('m.in_reply_to') - ?.tryGet('event_id'); + /// If this event is in reply to another event, this returns the event ID or + /// null if this event is not a reply. + String? inReplyToEventId({bool includingFallback = true}) { + final isFallback = content + .tryGetMap('m.relates_to') + ?.tryGet('is_falling_back'); + if (isFallback == true && !includingFallback) return null; + return content + .tryGetMap('m.relates_to') + ?.tryGetMap('m.in_reply_to') + ?.tryGet('event_id'); } /// Get whether this event has aggregated events from a certain [type] diff --git a/test/event_test.dart b/test/event_test.dart index 0bfb297f..d5b17d1d 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -79,7 +79,7 @@ void main() async { expect(event.formattedText, formatted_body); expect(event.body, body); expect(event.type, EventTypes.Message); - expect(event.relationshipType, RelationshipTypes.reply); + expect(event.inReplyToEventId(), '\$1234:example.com'); jsonObj['state_key'] = ''; final state = Event.fromJson(jsonObj, room); expect(state.eventId, id); @@ -178,8 +178,8 @@ void main() async { }; event = Event.fromJson(jsonObj, room); expect(event.messageType, MessageTypes.Text); - expect(event.relationshipType, RelationshipTypes.reply); - expect(event.relationshipEventId, '1234'); + expect(event.inReplyToEventId(), '1234'); + expect(event.relationshipEventId, null); }); test('relationship types', () async { @@ -212,8 +212,22 @@ void main() async { }, }; event = Event.fromJson(jsonObj, room); - expect(event.relationshipType, RelationshipTypes.reply); - expect(event.relationshipEventId, 'def'); + expect(event.inReplyToEventId(), '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 { From bc338bb2a9108391b5010020658c6014920bec29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Thu, 13 Nov 2025 10:54:37 +0100 Subject: [PATCH 8/8] build: Bump version to 4.0.0 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a29bc9..c1c97269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - chore: bump vodozemac version to v0.4.0 (Karthikeyan S) diff --git a/pubspec.yaml b/pubspec.yaml index ab79cdc7..7e9dc659 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 3.0.2 +version: 4.0.0 homepage: https://famedly.com repository: https://github.com/famedly/matrix-dart-sdk.git issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues