Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/matrix_api_lite/model/sync_update.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -170,6 +171,7 @@ class JoinedRoomUpdate extends SyncRoomUpdate {
RoomSummary? summary;
List<MatrixEvent>? state;
TimelineUpdate? timeline;
StickyEventsUpdate? sticky;
List<BasicEvent>? ephemeral;
List<BasicEvent>? accountData;
UnreadNotificationCounts? unreadNotifications;
Expand All @@ -178,6 +180,7 @@ class JoinedRoomUpdate extends SyncRoomUpdate {
this.summary,
this.state,
this.timeline,
this.sticky,
this.ephemeral,
this.accountData,
this.unreadNotifications,
Expand All @@ -190,6 +193,10 @@ class JoinedRoomUpdate extends SyncRoomUpdate {
?.map((i) => MatrixEvent.fromJson(i as Map<String, Object?>))
.toList(),
timeline = json.tryGetFromJson('timeline', TimelineUpdate.fromJson),
sticky = json.tryGetFromJson(
MSC4354ExtensionKeys.syncJoinedRoomSticky,
StickyEventsUpdate.fromJson,
),
ephemeral = json
.tryGetMap<String, List<Object?>>('ephemeral')?['events']
?.map((i) => BasicEvent.fromJson(i as Map<String, Object?>))
Expand All @@ -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(),
Expand Down
104 changes: 104 additions & 0 deletions lib/msc_extensions/msc_4354_sticky_events/models.dart
Original file line number Diff line number Diff line change
@@ -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<StickyEvent> events;

StickyEventsUpdate({
required this.events,
});

/// Creates a [StickyEventsUpdate] from JSON.
StickyEventsUpdate.fromJson(Map<String, Object?> json)
: events = (json['events'] as List?)
?.map((v) => StickyEvent.fromJson(v as Map<String, Object?>))
.toList() ??
[];

/// Serializes this [StickyEventsUpdate] to JSON.
Map<String, Object?> toJson() {
final data = <String, Object?>{};
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<String, Object?>? ?? {},
),
super.fromJson();

@override
Map<String, Object?> 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<String, Object?> json)
: durationMs = json[MSC4354ExtensionKeys.stickyDurationMs] as int? ?? 0;

Map<String, Object?> toJson() {
final data = <String, Object?>{};
data[MSC4354ExtensionKeys.stickyDurationMs] = durationMs;
return data;
}
}
206 changes: 206 additions & 0 deletions test/msc_extensions/msc_4354_sticky_events_test.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?> makeStickyEventJson({
Map<String, Object?>? content,
Map<String, Object?>? unsigned,
Map<String, Object?>? 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,
);
});
});
}