diff --git a/lib/matrix_api_lite/model/sync_update.dart b/lib/matrix_api_lite/model/sync_update.dart index d318ce1e5..3f478a023 100644 --- a/lib/matrix_api_lite/model/sync_update.dart +++ b/lib/matrix_api_lite/model/sync_update.dart @@ -22,6 +22,7 @@ */ import 'package:matrix/matrix_api_lite.dart'; +import 'package:matrix/msc_extensions/msc_4354_sticky_events/models.dart'; class SyncUpdate { String nextBatch; @@ -170,6 +171,7 @@ class JoinedRoomUpdate extends SyncRoomUpdate { RoomSummary? summary; List? state; TimelineUpdate? timeline; + StickyEventsUpdate? sticky; List? ephemeral; List? accountData; UnreadNotificationCounts? unreadNotifications; @@ -178,6 +180,7 @@ class JoinedRoomUpdate extends SyncRoomUpdate { this.summary, this.state, this.timeline, + this.sticky, this.ephemeral, this.accountData, this.unreadNotifications, @@ -190,6 +193,10 @@ class JoinedRoomUpdate extends SyncRoomUpdate { ?.map((i) => MatrixEvent.fromJson(i as Map)) .toList(), timeline = json.tryGetFromJson('timeline', TimelineUpdate.fromJson), + sticky = json.tryGetFromJson( + MSC4354ExtensionKeys.syncJoinedRoomSticky, + StickyEventsUpdate.fromJson, + ), ephemeral = json .tryGetMap>('ephemeral')?['events'] ?.map((i) => BasicEvent.fromJson(i as Map)) @@ -216,6 +223,9 @@ class JoinedRoomUpdate extends SyncRoomUpdate { if (timeline != null) { data['timeline'] = timeline!.toJson(); } + if (sticky != null) { + data[MSC4354ExtensionKeys.syncJoinedRoomSticky] = sticky!.toJson(); + } if (ephemeral != null) { data['ephemeral'] = { 'events': ephemeral!.map((i) => i.toJson()).toList(), diff --git a/lib/msc_extensions/msc_4354_sticky_events/models.dart b/lib/msc_extensions/msc_4354_sticky_events/models.dart new file mode 100644 index 000000000..a17906d09 --- /dev/null +++ b/lib/msc_extensions/msc_4354_sticky_events/models.dart @@ -0,0 +1,104 @@ +import 'package:matrix/matrix_api_lite/model/matrix_event.dart'; + +abstract class MSC4354ExtensionKeys { + /// The unstable prefix to use for the sticky events that get returned + /// in the /sync response inside rooms -> join -> room_id -> JoinedRoomUpdate. + static const syncJoinedRoomSticky = 'msc4354_sticky'; + + /// The unstable prefix to use for the sticky event duration in milliseconds + /// inside the sticky object of a sticky event. + static const stickyDurationMs = 'org.matrix.msc4354.sticky_duration_ms'; + + /// The unstable prefix to use for the sticky object inside a sticky event. + static const sticky = 'msc4354_sticky'; +} + +abstract class MSC4354StickyEventContent { + static const stickyKey = 'msc4354_sticky_key'; + + static const unsignedDurationTtlMs = 'msc4354_sticky_duration_ttl_ms'; +} + +class StickyEventsUpdate { + final List events; + + StickyEventsUpdate({ + required this.events, + }); + + /// Creates a [StickyEventsUpdate] from JSON. + StickyEventsUpdate.fromJson(Map json) + : events = (json['events'] as List?) + ?.map((v) => StickyEvent.fromJson(v as Map)) + .toList() ?? + []; + + /// Serializes this [StickyEventsUpdate] to JSON. + Map toJson() { + final data = {}; + data['events'] = events.map((i) => i.toJson()).toList(); + return data; + } +} + +class StickyEvent extends MatrixEvent { + final StickyEventDuration sticky; + + StickyEvent({ + required this.sticky, + required super.type, + required super.content, + required super.senderId, + super.stateKey, + required super.eventId, + super.roomId, + required super.originServerTs, + super.unsigned, + super.prevContent, + super.redacts, + }); + + StickyEvent.fromJson(super.json) + : sticky = StickyEventDuration.fromJson( + json[MSC4354ExtensionKeys.sticky] as Map? ?? {}, + ), + super.fromJson(); + + @override + Map toJson() { + final data = super.toJson(); + data[MSC4354ExtensionKeys.sticky] = sticky.toJson(); + return data; + } + + String? get stickyKey { + return content[MSC4354StickyEventContent.stickyKey] as String?; + } + + Duration? get unsignedDurationTtlMs { + final durationMs = + unsigned?[MSC4354StickyEventContent.unsignedDurationTtlMs] as int?; + + if (durationMs == null) return null; + + return Duration(milliseconds: durationMs); + } +} + +class StickyEventDuration { + final int durationMs; + + StickyEventDuration({ + required this.durationMs, + }); + + /// Creates a [StickyEventDuration] from JSON. + StickyEventDuration.fromJson(Map json) + : durationMs = json[MSC4354ExtensionKeys.stickyDurationMs] as int? ?? 0; + + Map toJson() { + final data = {}; + data[MSC4354ExtensionKeys.stickyDurationMs] = durationMs; + return data; + } +} diff --git a/test/msc_extensions/msc_4354_sticky_events_test.dart b/test/msc_extensions/msc_4354_sticky_events_test.dart new file mode 100644 index 000000000..c17126d76 --- /dev/null +++ b/test/msc_extensions/msc_4354_sticky_events_test.dart @@ -0,0 +1,206 @@ +import 'package:matrix/matrix_api_lite/model/sync_update.dart'; +import 'package:matrix/msc_extensions/msc_4354_sticky_events/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('StickyEventDuration', () { + test('fromJson parses durationMs', () { + final duration = StickyEventDuration.fromJson( + {MSC4354ExtensionKeys.stickyDurationMs: 30000}, + ); + expect(duration.durationMs, 30000); + }); + + test('fromJson defaults to 0 when key is missing', () { + final duration = StickyEventDuration.fromJson({}); + expect(duration.durationMs, 0); + }); + + test('toJson serializes correctly', () { + final json = StickyEventDuration(durationMs: 60000).toJson(); + expect(json[MSC4354ExtensionKeys.stickyDurationMs], 60000); + }); + }); + + group('StickyEvent', () { + Map makeStickyEventJson({ + Map? content, + Map? unsigned, + Map? sticky, + }) => + { + 'type': 'm.room.message', + 'content': content ?? {'body': 'hello'}, + 'sender': '@alice:example.com', + 'event_id': '\$event1', + 'origin_server_ts': 1234567890, + if (sticky != null) MSC4354ExtensionKeys.sticky: sticky, + if (unsigned != null) 'unsigned': unsigned, + }; + + test('fromJson parses correctly', () { + final event = StickyEvent.fromJson( + makeStickyEventJson( + sticky: {MSC4354ExtensionKeys.stickyDurationMs: 45000}, + ), + ); + expect(event.type, 'm.room.message'); + expect(event.senderId, '@alice:example.com'); + expect(event.eventId, '\$event1'); + expect(event.sticky.durationMs, 45000); + }); + + test('fromJson defaults sticky when key is absent', () { + final event = StickyEvent.fromJson(makeStickyEventJson()); + expect(event.sticky.durationMs, 0); + }); + + test('toJson round-trips correctly', () { + final json = StickyEvent.fromJson( + makeStickyEventJson( + sticky: {MSC4354ExtensionKeys.stickyDurationMs: 45000}, + ), + ).toJson(); + expect(json['type'], 'm.room.message'); + expect(json['event_id'], '\$event1'); + final s = json[MSC4354ExtensionKeys.sticky] as Map; + expect(s[MSC4354ExtensionKeys.stickyDurationMs], 45000); + }); + + test('stickyKey returns key from content', () { + final event = StickyEvent.fromJson( + makeStickyEventJson( + content: { + MSC4354StickyEventContent.stickyKey: 'my_key', + 'body': 'hi' + }, + ), + ); + expect(event.stickyKey, 'my_key'); + }); + + test('stickyKey returns null when missing', () { + final event = StickyEvent.fromJson(makeStickyEventJson()); + expect(event.stickyKey, isNull); + }); + + test('unsignedDurationTtlMs returns Duration when present', () { + final event = StickyEvent.fromJson( + makeStickyEventJson( + unsigned: {MSC4354StickyEventContent.unsignedDurationTtlMs: 30000}, + ), + ); + expect(event.unsignedDurationTtlMs, const Duration(milliseconds: 30000)); + }); + + test('unsignedDurationTtlMs returns null when unsigned is missing', () { + final event = StickyEvent.fromJson(makeStickyEventJson()); + expect(event.unsignedDurationTtlMs, isNull); + }); + }); + + group('StickyEventsUpdate', () { + test('fromJson parses events list', () { + final update = StickyEventsUpdate.fromJson({ + 'events': [ + { + 'type': 'm.room.message', + 'content': {'body': 'hello'}, + 'sender': '@alice:example.com', + 'event_id': '\$event1', + 'origin_server_ts': 1234567890, + MSC4354ExtensionKeys.sticky: { + MSC4354ExtensionKeys.stickyDurationMs: 10000, + }, + }, + ], + }); + expect(update.events.length, 1); + expect(update.events.first.eventId, '\$event1'); + expect(update.events.first.sticky.durationMs, 10000); + }); + + test('fromJson defaults to empty list when events is null', () { + expect(StickyEventsUpdate.fromJson({}).events, isEmpty); + }); + + test('toJson serializes events', () { + final update = StickyEventsUpdate( + events: [ + StickyEvent.fromJson({ + 'type': 'm.room.message', + 'content': {'body': 'hello'}, + 'sender': '@alice:example.com', + 'event_id': '\$event1', + 'origin_server_ts': 1234567890, + }), + ], + ); + expect((update.toJson()['events'] as List).length, 1); + }); + }); + + group('JoinedRoomUpdate with sticky events', () { + test('fromJson parses sticky field', () { + final update = JoinedRoomUpdate.fromJson({ + MSC4354ExtensionKeys.syncJoinedRoomSticky: { + 'events': [ + { + 'type': 'm.room.message', + 'content': {'body': 'sticky msg'}, + 'sender': '@alice:example.com', + 'event_id': '\$sticky1', + 'origin_server_ts': 1234567890, + MSC4354ExtensionKeys.sticky: { + MSC4354ExtensionKeys.stickyDurationMs: 20000, + }, + }, + ], + }, + }); + expect(update.sticky, isNotNull); + expect(update.sticky!.events.length, 1); + expect(update.sticky!.events.first.eventId, '\$sticky1'); + expect(update.sticky!.events.first.sticky.durationMs, 20000); + }); + + test('fromJson has null sticky when key is absent', () { + final update = JoinedRoomUpdate.fromJson({}); + expect(update.sticky, isNull); + }); + + test('toJson includes sticky when present', () { + final update = JoinedRoomUpdate( + sticky: StickyEventsUpdate( + events: [ + StickyEvent.fromJson({ + 'type': 'm.room.message', + 'content': {'body': 'test'}, + 'sender': '@alice:example.com', + 'event_id': '\$s1', + 'origin_server_ts': 1234567890, + MSC4354ExtensionKeys.sticky: { + MSC4354ExtensionKeys.stickyDurationMs: 15000, + }, + }), + ], + ), + ); + final json = update.toJson(); + expect( + json.containsKey(MSC4354ExtensionKeys.syncJoinedRoomSticky), + true, + ); + final stickyJson = json[MSC4354ExtensionKeys.syncJoinedRoomSticky] as Map; + expect((stickyJson['events'] as List).length, 1); + }); + + test('toJson omits sticky when null', () { + final json = JoinedRoomUpdate().toJson(); + expect( + json.containsKey(MSC4354ExtensionKeys.syncJoinedRoomSticky), + false, + ); + }); + }); +}