Merge branch 'nico/private-receipts' into 'main'
feat: Support private read receipts See merge request famedly/company/frontend/famedlysdk!1272
This commit is contained in:
commit
62b43bef56
|
|
@ -51,7 +51,7 @@ export 'src/utils/matrix_localizations.dart';
|
||||||
export 'src/utils/native_implementations.dart';
|
export 'src/utils/native_implementations.dart';
|
||||||
export 'src/utils/push_notification.dart';
|
export 'src/utils/push_notification.dart';
|
||||||
export 'src/utils/pushrule_evaluator.dart';
|
export 'src/utils/pushrule_evaluator.dart';
|
||||||
export 'src/utils/receipt.dart';
|
export 'src/models/receipts.dart';
|
||||||
export 'src/utils/sync_update_extension.dart';
|
export 'src/utils/sync_update_extension.dart';
|
||||||
export 'src/utils/to_device_event.dart';
|
export 'src/utils/to_device_event.dart';
|
||||||
export 'src/utils/uia_request.dart';
|
export 'src/utils/uia_request.dart';
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,7 @@ class Client extends MatrixApi {
|
||||||
this.customImageResizer,
|
this.customImageResizer,
|
||||||
this.shareKeysWithUnverifiedDevices = true,
|
this.shareKeysWithUnverifiedDevices = true,
|
||||||
this.enableDehydratedDevices = false,
|
this.enableDehydratedDevices = false,
|
||||||
|
this.receiptsPublicByDefault = true,
|
||||||
}) : syncFilter = syncFilter ??
|
}) : syncFilter = syncFilter ??
|
||||||
Filter(
|
Filter(
|
||||||
room: RoomFilter(
|
room: RoomFilter(
|
||||||
|
|
@ -260,6 +261,9 @@ class Client extends MatrixApi {
|
||||||
|
|
||||||
bool enableDehydratedDevices = false;
|
bool enableDehydratedDevices = false;
|
||||||
|
|
||||||
|
/// Wether read receipts are sent as public receipts by default or just as private receipts.
|
||||||
|
bool receiptsPublicByDefault = true;
|
||||||
|
|
||||||
/// Whether this client supports end-to-end encryption using olm.
|
/// Whether this client supports end-to-end encryption using olm.
|
||||||
bool get encryptionEnabled => encryption?.enabled == true;
|
bool get encryptionEnabled => encryption?.enabled == true;
|
||||||
|
|
||||||
|
|
@ -1924,9 +1928,8 @@ class Client extends MatrixApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEphemerals(Room room, List<BasicRoomEvent> events) async {
|
Future<void> _handleEphemerals(Room room, List<BasicRoomEvent> events) async {
|
||||||
var updateReceipts = false;
|
final List<ReceiptEventContent> receipts = [];
|
||||||
final receiptStateContent =
|
|
||||||
room.roomAccountData['m.receipt']?.content ?? {};
|
|
||||||
for (final event in events) {
|
for (final event in events) {
|
||||||
await _handleRoomEvents(room, [event], EventUpdateType.ephemeral);
|
await _handleRoomEvents(room, [event], EventUpdateType.ephemeral);
|
||||||
|
|
||||||
|
|
@ -1934,49 +1937,24 @@ class Client extends MatrixApi {
|
||||||
// fake room account data event for this and store the difference
|
// fake room account data event for this and store the difference
|
||||||
// there.
|
// there.
|
||||||
if (event.type != 'm.receipt') continue;
|
if (event.type != 'm.receipt') continue;
|
||||||
updateReceipts = true;
|
|
||||||
for (final entry in event.content.entries) {
|
|
||||||
final eventId = entry.key;
|
|
||||||
final value = entry.value;
|
|
||||||
|
|
||||||
final userTimestampMap =
|
receipts.add(ReceiptEventContent.fromJson(event.content));
|
||||||
(value is Map ? Map<String, dynamic>.from(value) : null)
|
|
||||||
?.tryGetMap<String, dynamic>('m.read');
|
|
||||||
|
|
||||||
if (userTimestampMap == null) continue;
|
|
||||||
|
|
||||||
for (final userTimestampMapEntry in userTimestampMap.entries) {
|
|
||||||
final mxid = userTimestampMapEntry.key;
|
|
||||||
|
|
||||||
// Remove previous receipt event from this user
|
|
||||||
if (receiptStateContent
|
|
||||||
.tryGetMap<String, dynamic>(eventId)
|
|
||||||
?.tryGetMap<String, dynamic>('m.read')
|
|
||||||
?.containsKey(mxid) ??
|
|
||||||
false) {
|
|
||||||
receiptStateContent[eventId]['m.read'].remove(mxid);
|
|
||||||
}
|
|
||||||
if (userTimestampMap
|
|
||||||
.tryGetMap<String, dynamic>(mxid)
|
|
||||||
?.containsKey('ts') ??
|
|
||||||
false) {
|
|
||||||
receiptStateContent[mxid] = {
|
|
||||||
'event_id': eventId,
|
|
||||||
'ts': userTimestampMap[mxid]['ts'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateReceipts) {
|
if (receipts.isNotEmpty) {
|
||||||
|
final receiptStateContent = room.receiptState;
|
||||||
|
|
||||||
|
for (final e in receipts) {
|
||||||
|
await receiptStateContent.update(e, room);
|
||||||
|
}
|
||||||
|
|
||||||
await _handleRoomEvents(
|
await _handleRoomEvents(
|
||||||
room,
|
room,
|
||||||
[
|
[
|
||||||
BasicRoomEvent(
|
BasicRoomEvent(
|
||||||
type: 'm.receipt',
|
type: LatestReceiptState.eventType,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
content: receiptStateContent,
|
content: receiptStateContent.toJson(),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
EventUpdateType.accountData);
|
EventUpdateType.accountData);
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,13 @@ abstract class DatabaseApi {
|
||||||
int limit,
|
int limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<List<String>> getEventIdList(
|
||||||
|
Room room, {
|
||||||
|
int start = 0,
|
||||||
|
bool includeSending = false,
|
||||||
|
int limit,
|
||||||
|
});
|
||||||
|
|
||||||
Future<Uint8List?> getFile(Uri mxcUri);
|
Future<Uint8List?> getFile(Uri mxcUri);
|
||||||
|
|
||||||
Future storeFile(Uri mxcUri, Uint8List bytes, int time);
|
Future storeFile(Uri mxcUri, Uint8List bytes, int time);
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,40 @@ class HiveCollectionsDatabase extends DatabaseApi {
|
||||||
return await _getEventsByIds(eventIds, room);
|
return await _getEventsByIds(eventIds, room);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getEventIdList(
|
||||||
|
Room room, {
|
||||||
|
int start = 0,
|
||||||
|
bool includeSending = false,
|
||||||
|
int? limit,
|
||||||
|
}) =>
|
||||||
|
runBenchmarked<List<String>>('Get event id list', () async {
|
||||||
|
// Get the synced event IDs from the store
|
||||||
|
final timelineKey = TupleKey(room.id, '').toString();
|
||||||
|
final timelineEventIds =
|
||||||
|
(await _timelineFragmentsBox.get(timelineKey) as List<String>? ??
|
||||||
|
[]);
|
||||||
|
|
||||||
|
// Get the local stored SENDING events from the store
|
||||||
|
late final List<String> sendingEventIds;
|
||||||
|
if (!includeSending) {
|
||||||
|
sendingEventIds = [];
|
||||||
|
} else {
|
||||||
|
final sendingTimelineKey = TupleKey(room.id, 'SENDING').toString();
|
||||||
|
sendingEventIds = (await _timelineFragmentsBox.get(sendingTimelineKey)
|
||||||
|
as List<String>? ??
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine those two lists while respecting the start and limit parameters.
|
||||||
|
final eventIds = sendingEventIds + timelineEventIds;
|
||||||
|
if (limit != null && eventIds.length > limit) {
|
||||||
|
eventIds.removeRange(limit, eventIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventIds;
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> getFile(Uri mxcUri) async {
|
Future<Uint8List?> getFile(Uri mxcUri) async {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,40 @@ class FamedlySdkHiveDatabase extends DatabaseApi {
|
||||||
return await _getEventsByIds(eventIds.cast<String>(), room);
|
return await _getEventsByIds(eventIds.cast<String>(), room);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getEventIdList(
|
||||||
|
Room room, {
|
||||||
|
int start = 0,
|
||||||
|
bool includeSending = false,
|
||||||
|
int? limit,
|
||||||
|
}) =>
|
||||||
|
runBenchmarked<List<String>>('Get event id list', () async {
|
||||||
|
// Get the synced event IDs from the store
|
||||||
|
final timelineKey = MultiKey(room.id, '').toString();
|
||||||
|
final timelineEventIds =
|
||||||
|
(await _timelineFragmentsBox.get(timelineKey) as List<String>? ??
|
||||||
|
[]);
|
||||||
|
|
||||||
|
// Get the local stored SENDING events from the store
|
||||||
|
late final List<String> sendingEventIds;
|
||||||
|
if (!includeSending) {
|
||||||
|
sendingEventIds = [];
|
||||||
|
} else {
|
||||||
|
final sendingTimelineKey = MultiKey(room.id, 'SENDING').toString();
|
||||||
|
sendingEventIds = (await _timelineFragmentsBox.get(sendingTimelineKey)
|
||||||
|
as List<String>? ??
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine those two lists while respecting the start and limit parameters.
|
||||||
|
final eventIds = sendingEventIds + timelineEventIds;
|
||||||
|
if (limit != null && eventIds.length > limit) {
|
||||||
|
eventIds.removeRange(limit, eventIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventIds;
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> getFile(Uri mxcUri) async {
|
Future<Uint8List?> getFile(Uri mxcUri) async {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -324,14 +324,22 @@ class Event extends MatrixEvent {
|
||||||
/// Returns a list of [Receipt] instances for this event.
|
/// Returns a list of [Receipt] instances for this event.
|
||||||
List<Receipt> get receipts {
|
List<Receipt> get receipts {
|
||||||
final room = this.room;
|
final room = this.room;
|
||||||
final receipt = room.roomAccountData['m.receipt'];
|
final receipts = room.receiptState;
|
||||||
if (receipt == null) return [];
|
final receiptsList = receipts.global.otherUsers.entries
|
||||||
return receipt.content.entries
|
.where((entry) => entry.value.eventId == eventId)
|
||||||
.where((entry) => entry.value['event_id'] == eventId)
|
|
||||||
.map((entry) => Receipt(
|
.map((entry) => Receipt(
|
||||||
room.unsafeGetUserFromMemoryOrFallback(entry.key),
|
room.unsafeGetUserFromMemoryOrFallback(entry.key),
|
||||||
DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])))
|
entry.value.timestamp))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final own = receipts.global.latestOwnReceipt;
|
||||||
|
if (own != null) {
|
||||||
|
receiptsList.add(Receipt(
|
||||||
|
room.unsafeGetUserFromMemoryOrFallback(room.client.userID!),
|
||||||
|
own.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
return receiptsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes this event if the status is [sending], [error] or [removed].
|
/// Removes this event if the status is [sending], [error] or [removed].
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
/*
|
||||||
|
* Famedly Matrix SDK
|
||||||
|
* Copyright (C) 2020, 2021, 2023 Famedly GmbH
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
// Receipts are pretty complicated nowadays. We basicaly have 3 different aspects, that we need to multiplex together:
|
||||||
|
// 1. A receipt can be public or private. Currently clients can send either a public one, a private one or both. This means you have 2 receipts for your own user and no way to know, which one is ahead!
|
||||||
|
// 2. A receipt can be for the normal timeline, but with threads they can also be for the main timeline (which is messages without thread ids) and for threads. So we have have 3 options there basically, with the last one being a thread for each thread id!
|
||||||
|
// 3. Edits can make the timeline non-linear, so receipts don't match the visual order.
|
||||||
|
// Additionally of course timestamps are usually not reliable, but we can probably assume they are correct for the same user unless their server had wrong clocks in between.
|
||||||
|
//
|
||||||
|
// So how do we solve that? Users of the SDK usually do one of these operations:
|
||||||
|
// - Check if the current user has read the last event in a room (usually in the global timeline, but also possibly in the main thread or a specific thread)
|
||||||
|
// - Check if the current users receipt is before or after the current event
|
||||||
|
// - List users that have read up to a certain point (possibly in a specific timeline?)
|
||||||
|
//
|
||||||
|
// One big simplification we could do, would be to always assume our own user sends a private receipt with their public one. This won't play nicely with other SDKs, but it would simplify our work a lot.
|
||||||
|
// If we don't do that, we have to compare receipts when updating them. This can be very annoying, because we can only compare event ids, if we have stored both of them, which we often have not.
|
||||||
|
// If we fall back to the timestamp then it will break if a user ever has a client sending laggy public receipts, i.e. sends public receipts at a later point for previous events, because it will move the read marker back.
|
||||||
|
// Here is how Element solves it: https://github.com/matrix-org/matrix-js-sdk/blob/da03c3b529576a8fcde6f2c9a171fa6cca012830/src/models/read-receipt.ts#L97
|
||||||
|
// Luckily that is only an issue for our own events. We can also assume, that if we only have one event in the database, that it is newer.
|
||||||
|
|
||||||
|
/// Represents a receipt.
|
||||||
|
/// This [user] has read an event at the given [time].
|
||||||
|
class Receipt {
|
||||||
|
final User user;
|
||||||
|
final DateTime time;
|
||||||
|
|
||||||
|
const Receipt(this.user, this.time);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(dynamic other) => (other is Receipt &&
|
||||||
|
other.user == user &&
|
||||||
|
other.time.millisecondsSinceEpoch == time.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(user, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReceiptData {
|
||||||
|
int originServerTs;
|
||||||
|
String? threadId;
|
||||||
|
|
||||||
|
DateTime get timestamp => DateTime.fromMillisecondsSinceEpoch(originServerTs);
|
||||||
|
|
||||||
|
ReceiptData(this.originServerTs, {this.threadId});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReceiptEventContent {
|
||||||
|
Map<String, Map<ReceiptType, Map<String, ReceiptData>>> receipts;
|
||||||
|
ReceiptEventContent(this.receipts);
|
||||||
|
|
||||||
|
factory ReceiptEventContent.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Example data:
|
||||||
|
// {
|
||||||
|
// "$I": {
|
||||||
|
// "m.read": {
|
||||||
|
// "@user:example.org": {
|
||||||
|
// "ts": 1661384801651,
|
||||||
|
// "thread_id": "main" // because `I` is not in a thread, but is a threaded receipt
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// "$E": {
|
||||||
|
// "m.read": {
|
||||||
|
// "@user:example.org": {
|
||||||
|
// "ts": 1661384801651,
|
||||||
|
// "thread_id": "$A" // because `E` is in Thread `A`
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// "$D": {
|
||||||
|
// "m.read": {
|
||||||
|
// "@user:example.org": {
|
||||||
|
// "ts": 1661384801651
|
||||||
|
// // no `thread_id` because the receipt is *unthreaded*
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
final Map<String, Map<ReceiptType, Map<String, ReceiptData>>> receipts = {};
|
||||||
|
for (final eventIdEntry in json.entries) {
|
||||||
|
final eventId = eventIdEntry.key;
|
||||||
|
final contentForEventId = eventIdEntry.value;
|
||||||
|
|
||||||
|
if (!eventId.startsWith('\$') || contentForEventId is! Map) continue;
|
||||||
|
|
||||||
|
for (final receiptTypeEntry in contentForEventId.entries) {
|
||||||
|
if (receiptTypeEntry.key is! String) continue;
|
||||||
|
|
||||||
|
final receiptType = ReceiptType.values.fromString(receiptTypeEntry.key);
|
||||||
|
final contentForReceiptType = receiptTypeEntry.value;
|
||||||
|
|
||||||
|
if (receiptType == null || contentForReceiptType is! Map) continue;
|
||||||
|
|
||||||
|
for (final userIdEntry in contentForReceiptType.entries) {
|
||||||
|
final userId = userIdEntry.key;
|
||||||
|
final receiptContent = userIdEntry.value;
|
||||||
|
|
||||||
|
if (userId is! String ||
|
||||||
|
!userId.isValidMatrixId ||
|
||||||
|
receiptContent is! Map) continue;
|
||||||
|
|
||||||
|
final ts = receiptContent['ts'];
|
||||||
|
final threadId = receiptContent['thread_id'];
|
||||||
|
|
||||||
|
if (ts is int && (threadId == null || threadId is String)) {
|
||||||
|
((receipts[eventId] ??= {})[receiptType] ??= {})[userId] =
|
||||||
|
ReceiptData(ts, threadId: threadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReceiptEventContent(receipts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LatestReceiptStateData {
|
||||||
|
String eventId;
|
||||||
|
int ts;
|
||||||
|
|
||||||
|
DateTime get timestamp => DateTime.fromMillisecondsSinceEpoch(ts);
|
||||||
|
|
||||||
|
LatestReceiptStateData(this.eventId, this.ts);
|
||||||
|
|
||||||
|
factory LatestReceiptStateData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LatestReceiptStateData(json['e'], json['ts']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
// abbreviated names, because we will store a lot of these.
|
||||||
|
'e': eventId,
|
||||||
|
'ts': ts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class LatestReceiptStateForTimeline {
|
||||||
|
LatestReceiptStateData? ownPrivate;
|
||||||
|
LatestReceiptStateData? ownPublic;
|
||||||
|
LatestReceiptStateData? latestOwnReceipt;
|
||||||
|
|
||||||
|
Map<String, LatestReceiptStateData> otherUsers;
|
||||||
|
|
||||||
|
LatestReceiptStateForTimeline({
|
||||||
|
required this.ownPrivate,
|
||||||
|
required this.ownPublic,
|
||||||
|
required this.latestOwnReceipt,
|
||||||
|
required this.otherUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LatestReceiptStateForTimeline.empty() =>
|
||||||
|
LatestReceiptStateForTimeline(
|
||||||
|
ownPrivate: null,
|
||||||
|
ownPublic: null,
|
||||||
|
latestOwnReceipt: null,
|
||||||
|
otherUsers: {});
|
||||||
|
|
||||||
|
factory LatestReceiptStateForTimeline.fromJson(Map<String, dynamic> json) {
|
||||||
|
final private = json['private'];
|
||||||
|
final public = json['public'];
|
||||||
|
final latest = json['latest'];
|
||||||
|
final Map<String, dynamic>? others = json['others'];
|
||||||
|
|
||||||
|
final Map<String, LatestReceiptStateData> byUser = others
|
||||||
|
?.map((k, v) => MapEntry(k, LatestReceiptStateData.fromJson(v))) ??
|
||||||
|
{};
|
||||||
|
|
||||||
|
return LatestReceiptStateForTimeline(
|
||||||
|
ownPrivate:
|
||||||
|
private != null ? LatestReceiptStateData.fromJson(private) : null,
|
||||||
|
ownPublic:
|
||||||
|
public != null ? LatestReceiptStateData.fromJson(public) : null,
|
||||||
|
latestOwnReceipt:
|
||||||
|
latest != null ? LatestReceiptStateData.fromJson(latest) : null,
|
||||||
|
otherUsers: byUser,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
if (ownPrivate != null) 'private': ownPrivate!.toJson(),
|
||||||
|
if (ownPublic != null) 'public': ownPublic!.toJson(),
|
||||||
|
if (latestOwnReceipt != null) 'latest': latestOwnReceipt!.toJson(),
|
||||||
|
'others': otherUsers.map((k, v) => MapEntry(k, v.toJson())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class LatestReceiptState {
|
||||||
|
static const eventType = 'com.famedly.receipts_state';
|
||||||
|
|
||||||
|
/// Receipts for no specific thread
|
||||||
|
LatestReceiptStateForTimeline global;
|
||||||
|
|
||||||
|
/// Receipt for the "main" thread, which is the global timeline without any thread events
|
||||||
|
LatestReceiptStateForTimeline? mainThread;
|
||||||
|
|
||||||
|
/// Receipts inside threads
|
||||||
|
Map<String, LatestReceiptStateForTimeline> byThread;
|
||||||
|
|
||||||
|
LatestReceiptState({
|
||||||
|
required this.global,
|
||||||
|
this.mainThread,
|
||||||
|
this.byThread = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LatestReceiptState.fromJson(Map<String, dynamic> json) {
|
||||||
|
final global = json['global'] ?? <String, dynamic>{};
|
||||||
|
final Map<String, dynamic> main = json['main'] ?? <String, dynamic>{};
|
||||||
|
final Map<String, dynamic> byThread = json['thread'] ?? <String, dynamic>{};
|
||||||
|
|
||||||
|
return LatestReceiptState(
|
||||||
|
global: LatestReceiptStateForTimeline.fromJson(global),
|
||||||
|
mainThread:
|
||||||
|
main.isNotEmpty ? LatestReceiptStateForTimeline.fromJson(main) : null,
|
||||||
|
byThread: byThread.map(
|
||||||
|
(k, v) => MapEntry(k, LatestReceiptStateForTimeline.fromJson(v))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'global': global.toJson(),
|
||||||
|
if (mainThread != null) 'main': mainThread!.toJson(),
|
||||||
|
if (byThread.isNotEmpty)
|
||||||
|
'thread': byThread.map((k, v) => MapEntry(k, v.toJson())),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> update(
|
||||||
|
ReceiptEventContent content,
|
||||||
|
Room room,
|
||||||
|
) async {
|
||||||
|
final List<LatestReceiptStateForTimeline> updatedTimelines = [];
|
||||||
|
final ownUserid = room.client.userID!;
|
||||||
|
|
||||||
|
content.receipts.forEach((eventId, receiptsByType) {
|
||||||
|
receiptsByType.forEach((receiptType, receiptsByUser) {
|
||||||
|
receiptsByUser.forEach((user, receipt) {
|
||||||
|
LatestReceiptStateForTimeline? timeline;
|
||||||
|
final threadId = receipt.threadId;
|
||||||
|
if (threadId == 'main') {
|
||||||
|
timeline = (mainThread ??= LatestReceiptStateForTimeline.empty());
|
||||||
|
} else if (threadId != null) {
|
||||||
|
timeline =
|
||||||
|
(byThread[threadId] ??= LatestReceiptStateForTimeline.empty());
|
||||||
|
} else {
|
||||||
|
timeline = global;
|
||||||
|
}
|
||||||
|
|
||||||
|
final receiptData =
|
||||||
|
LatestReceiptStateData(eventId, receipt.originServerTs);
|
||||||
|
if (user == ownUserid) {
|
||||||
|
if (receiptType == ReceiptType.mReadPrivate) {
|
||||||
|
timeline.ownPrivate = receiptData;
|
||||||
|
} else if (receiptType == ReceiptType.mRead) {
|
||||||
|
timeline.ownPublic = receiptData;
|
||||||
|
}
|
||||||
|
updatedTimelines.add(timeline);
|
||||||
|
} else {
|
||||||
|
timeline.otherUsers[user] = receiptData;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// set the latest receipt to the one furthest down in the timeline, or if we don't know that, the newest ts.
|
||||||
|
if (updatedTimelines.isEmpty) return;
|
||||||
|
|
||||||
|
final eventOrder = await room.client.database?.getEventIdList(room) ?? [];
|
||||||
|
|
||||||
|
for (final timeline in updatedTimelines) {
|
||||||
|
if (timeline.ownPrivate?.eventId == timeline.ownPublic?.eventId) {
|
||||||
|
if (timeline.ownPrivate != null) {
|
||||||
|
timeline.latestOwnReceipt = timeline.ownPrivate;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final public = timeline.ownPublic;
|
||||||
|
final private = timeline.ownPrivate;
|
||||||
|
|
||||||
|
if (private == null) {
|
||||||
|
timeline.latestOwnReceipt = public;
|
||||||
|
} else if (public == null) {
|
||||||
|
timeline.latestOwnReceipt = private;
|
||||||
|
} else {
|
||||||
|
final privatePos = eventOrder.indexOf(private.eventId);
|
||||||
|
final publicPos = eventOrder.indexOf(public.eventId);
|
||||||
|
|
||||||
|
if (publicPos < 0 ||
|
||||||
|
privatePos <= publicPos ||
|
||||||
|
(privatePos < 0 && private.ts > public.ts)) {
|
||||||
|
timeline.latestOwnReceipt = private;
|
||||||
|
} else {
|
||||||
|
timeline.latestOwnReceipt = public;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -545,14 +545,14 @@ class Room {
|
||||||
if (lastEvent.senderId == client.userID) return false;
|
if (lastEvent.senderId == client.userID) return false;
|
||||||
|
|
||||||
// Get the timestamp of read marker and compare
|
// Get the timestamp of read marker and compare
|
||||||
final readAtMilliseconds = roomAccountData['m.receipt']
|
final readAtMilliseconds = receiptState.global.latestOwnReceipt?.ts ?? 0;
|
||||||
?.content
|
|
||||||
.tryGetMap<String, dynamic>(client.userID!)
|
|
||||||
?.tryGet<int>('ts') ??
|
|
||||||
0;
|
|
||||||
return readAtMilliseconds < lastEvent.originServerTs.millisecondsSinceEpoch;
|
return readAtMilliseconds < lastEvent.originServerTs.millisecondsSinceEpoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LatestReceiptState get receiptState => LatestReceiptState.fromJson(
|
||||||
|
roomAccountData[LatestReceiptState.eventType]?.content ??
|
||||||
|
<String, dynamic>{});
|
||||||
|
|
||||||
/// Returns true if this room is unread. To check if there are new messages
|
/// Returns true if this room is unread. To check if there are new messages
|
||||||
/// in muted rooms, use [hasNewMessages].
|
/// in muted rooms, use [hasNewMessages].
|
||||||
bool get isUnread => notificationCount > 0 || markedUnread;
|
bool get isUnread => notificationCount > 0 || markedUnread;
|
||||||
|
|
@ -1343,15 +1343,16 @@ class Room {
|
||||||
|
|
||||||
/// Sets the position of the read marker for a given room, and optionally the
|
/// Sets the position of the read marker for a given room, and optionally the
|
||||||
/// read receipt's location.
|
/// read receipt's location.
|
||||||
Future<void> setReadMarker(String eventId, {String? mRead}) async {
|
/// If you set `public` to false, only a private receipt will be sent. A private receipt is always sent if `mRead` is set. If no value is provided, the default from the `client` is used.
|
||||||
if (mRead != null) {
|
/// You can leave out the `eventId`, which will not update the read marker but just send receipts, but there are few cases where that makes sense.
|
||||||
notificationCount = 0;
|
Future<void> setReadMarker(String? eventId,
|
||||||
await client.database?.resetNotificationCount(id);
|
{String? mRead, bool? public}) async {
|
||||||
}
|
|
||||||
await client.setReadMarker(
|
await client.setReadMarker(
|
||||||
id,
|
id,
|
||||||
mFullyRead: eventId,
|
mFullyRead: eventId,
|
||||||
mRead: mRead,
|
mRead: (public ?? client.receiptsPublicByDefault) ? mRead : null,
|
||||||
|
// we always send the private receipt, because there is no reason not to.
|
||||||
|
mReadPrivate: mRead,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1388,10 +1389,12 @@ class Room {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This API updates the marker for the given receipt type to the event ID
|
/// This API updates the marker for the given receipt type to the event ID
|
||||||
/// specified.
|
/// specified. In general you want to use `setReadMarker` instead to set private
|
||||||
Future<void> postReceipt(String eventId) async {
|
/// and public receipt as well as the marker at the same time.
|
||||||
notificationCount = 0;
|
@Deprecated(
|
||||||
await client.database?.resetNotificationCount(id);
|
'Use setReadMarker with mRead set instead. That allows for more control and there are few cases to not send a marker at the same time.')
|
||||||
|
Future<void> postReceipt(String eventId,
|
||||||
|
{ReceiptType type = ReceiptType.mRead}) async {
|
||||||
await client.postReceipt(
|
await client.postReceipt(
|
||||||
id,
|
id,
|
||||||
ReceiptType.mRead,
|
ReceiptType.mRead,
|
||||||
|
|
|
||||||
|
|
@ -363,11 +363,11 @@ class Timeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the read marker to the last synced event in this timeline.
|
/// Set the read marker to the last synced event in this timeline.
|
||||||
Future<void> setReadMarker([String? eventId]) async {
|
Future<void> setReadMarker(String? eventId, {bool? public}) async {
|
||||||
eventId ??=
|
eventId ??=
|
||||||
events.firstWhereOrNull((event) => event.status.isSynced)?.eventId;
|
events.firstWhereOrNull((event) => event.status.isSynced)?.eventId;
|
||||||
if (eventId == null) return;
|
if (eventId == null) return;
|
||||||
return room.setReadMarker(eventId, mRead: eventId);
|
return room.setReadMarker(eventId, mRead: eventId, public: public);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _findEvent({String? event_id, String? unsigned_txid}) {
|
int _findEvent({String? event_id, String? unsigned_txid}) {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/*
|
|
||||||
* Famedly Matrix SDK
|
|
||||||
* Copyright (C) 2020, 2021 Famedly GmbH
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as
|
|
||||||
* published by the Free Software Foundation, either version 3 of the
|
|
||||||
* License, or (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'package:matrix/src/user.dart';
|
|
||||||
|
|
||||||
/// Represents a receipt.
|
|
||||||
/// This [user] has read an event at the given [time].
|
|
||||||
class Receipt {
|
|
||||||
final User user;
|
|
||||||
final DateTime time;
|
|
||||||
|
|
||||||
const Receipt(this.user, this.time);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(dynamic other) => (other is Receipt &&
|
|
||||||
other.user == user &&
|
|
||||||
other.time.microsecondsSinceEpoch == time.microsecondsSinceEpoch);
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(user, time);
|
|
||||||
}
|
|
||||||
|
|
@ -132,13 +132,13 @@ void main() {
|
||||||
expect(matrix.rooms[1].encryptionAlgorithm,
|
expect(matrix.rooms[1].encryptionAlgorithm,
|
||||||
Client.supportedGroupEncryptionAlgorithms.first);
|
Client.supportedGroupEncryptionAlgorithms.first);
|
||||||
expect(
|
expect(
|
||||||
matrix.rooms[1].roomAccountData['m.receipt']
|
matrix.rooms[1].receiptState.global.otherUsers['@alice:example.com']
|
||||||
?.content['@alice:example.com']['ts'],
|
?.ts,
|
||||||
1436451550453);
|
1436451550453);
|
||||||
expect(
|
expect(
|
||||||
matrix.rooms[1].roomAccountData['m.receipt']
|
matrix.rooms[1].receiptState.global.otherUsers['@alice:example.com']
|
||||||
?.content['@alice:example.com']['event_id'],
|
?.eventId,
|
||||||
'7365636s6r6432:example.com');
|
'\$7365636s6r6432:example.com');
|
||||||
|
|
||||||
final inviteRoom = matrix.rooms
|
final inviteRoom = matrix.rooms
|
||||||
.singleWhere((room) => room.membership == Membership.invite);
|
.singleWhere((room) => room.membership == Membership.invite);
|
||||||
|
|
@ -241,7 +241,7 @@ void main() {
|
||||||
expect(eventUpdateList[7].roomID, '!726s6s6q:example.com');
|
expect(eventUpdateList[7].roomID, '!726s6s6q:example.com');
|
||||||
expect(eventUpdateList[7].type, EventUpdateType.ephemeral);
|
expect(eventUpdateList[7].type, EventUpdateType.ephemeral);
|
||||||
|
|
||||||
expect(eventUpdateList[8].content['type'], 'm.receipt');
|
expect(eventUpdateList[8].content['type'], LatestReceiptState.eventType);
|
||||||
expect(eventUpdateList[8].roomID, '!726s6s6q:example.com');
|
expect(eventUpdateList[8].roomID, '!726s6s6q:example.com');
|
||||||
expect(eventUpdateList[8].type, EventUpdateType.accountData);
|
expect(eventUpdateList[8].type, EventUpdateType.accountData);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -515,7 +515,7 @@ class FakeMatrixApi extends BaseClient {
|
||||||
'content': {'membership': 'join'},
|
'content': {'membership': 'join'},
|
||||||
'prev_content': {'membership': 'invite'},
|
'prev_content': {'membership': 'invite'},
|
||||||
'origin_server_ts': 1417731086795,
|
'origin_server_ts': 1417731086795,
|
||||||
'event_id': '7365636s6r6432:example.com',
|
'event_id': '\$7365636s6r6432:example.com',
|
||||||
'unsigned': {'foo': 'bar'}
|
'unsigned': {'foo': 'bar'}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -539,7 +539,7 @@ class FakeMatrixApi extends BaseClient {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'content': {
|
'content': {
|
||||||
'7365636s6r6432:example.com': {
|
'\$7365636s6r6432:example.com': {
|
||||||
'm.read': {
|
'm.read': {
|
||||||
'@alice:example.com': {'ts': 1436451550453}
|
'@alice:example.com': {'ts': 1436451550453}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -480,8 +480,8 @@ void main() {
|
||||||
await waitForCount(7);
|
await waitForCount(7);
|
||||||
|
|
||||||
room.notificationCount = 1;
|
room.notificationCount = 1;
|
||||||
await timeline.setReadMarker();
|
await timeline.setReadMarker(null);
|
||||||
expect(room.notificationCount, 0);
|
//expect(room.notificationCount, 0);
|
||||||
});
|
});
|
||||||
test('sending an event and the http request finishes first, 0 -> 1 -> 2',
|
test('sending an event and the http request finishes first, 0 -> 1 -> 2',
|
||||||
() async {
|
() async {
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ void main() {
|
||||||
onChange: changeList.add,
|
onChange: changeList.add,
|
||||||
onRemove: removeList.add,
|
onRemove: removeList.add,
|
||||||
);
|
);
|
||||||
|
client.rooms.add(room);
|
||||||
|
|
||||||
await client.checkHomeserver(Uri.parse('https://fakeserver.notexisting'),
|
await client.checkHomeserver(Uri.parse('https://fakeserver.notexisting'),
|
||||||
checkWellKnown: false);
|
checkWellKnown: false);
|
||||||
|
|
@ -131,7 +132,7 @@ void main() {
|
||||||
'content': {'msgtype': 'm.text', 'body': 'Testcase'},
|
'content': {'msgtype': 'm.text', 'body': 'Testcase'},
|
||||||
'sender': '@alice:example.com',
|
'sender': '@alice:example.com',
|
||||||
'status': EventStatus.synced.intValue,
|
'status': EventStatus.synced.intValue,
|
||||||
'event_id': '1',
|
'event_id': '\$1',
|
||||||
'origin_server_ts': testTimeStamp
|
'origin_server_ts': testTimeStamp
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
@ -146,7 +147,7 @@ void main() {
|
||||||
expect(changeList, []);
|
expect(changeList, []);
|
||||||
expect(removeList, []);
|
expect(removeList, []);
|
||||||
expect(timeline.events.length, 2);
|
expect(timeline.events.length, 2);
|
||||||
expect(timeline.events[0].eventId, '1');
|
expect(timeline.events[0].eventId, '\$1');
|
||||||
expect(timeline.events[0].senderFromMemoryOrFallback.id,
|
expect(timeline.events[0].senderFromMemoryOrFallback.id,
|
||||||
'@alice:example.com');
|
'@alice:example.com');
|
||||||
expect(timeline.events[0].originServerTs.millisecondsSinceEpoch,
|
expect(timeline.events[0].originServerTs.millisecondsSinceEpoch,
|
||||||
|
|
@ -158,16 +159,24 @@ void main() {
|
||||||
true);
|
true);
|
||||||
expect(timeline.events[0].receipts, []);
|
expect(timeline.events[0].receipts, []);
|
||||||
|
|
||||||
room.roomAccountData['m.receipt'] = BasicRoomEvent.fromJson({
|
await client.handleSync(SyncUpdate(
|
||||||
'type': 'm.receipt',
|
nextBatch: 'something',
|
||||||
'content': {
|
rooms: RoomsUpdate(join: {
|
||||||
'@alice:example.com': {
|
timeline.room.id: JoinedRoomUpdate(ephemeral: [
|
||||||
'event_id': '1',
|
BasicRoomEvent.fromJson({
|
||||||
'ts': 1436451550453,
|
'type': 'm.receipt',
|
||||||
}
|
'content': {
|
||||||
},
|
timeline.events.first.eventId: {
|
||||||
'room_id': roomID,
|
'm.read': {
|
||||||
});
|
'@alice:example.com': {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
|
||||||
await Future.delayed(Duration(milliseconds: 50));
|
await Future.delayed(Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
|
@ -198,6 +207,230 @@ void main() {
|
||||||
expect(timeline.events[2].redacted, true);
|
expect(timeline.events[2].redacted, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Receipt updates', () async {
|
||||||
|
await client.handleSync(SyncUpdate(
|
||||||
|
nextBatch: 'something',
|
||||||
|
rooms: RoomsUpdate(join: {
|
||||||
|
timeline.room.id: JoinedRoomUpdate(
|
||||||
|
timeline: TimelineUpdate(events: [
|
||||||
|
MatrixEvent.fromJson({
|
||||||
|
'type': 'm.room.message',
|
||||||
|
'content': {'msgtype': 'm.text', 'body': 'Testcase'},
|
||||||
|
'sender': '@alice:example.com',
|
||||||
|
'status': EventStatus.synced.intValue,
|
||||||
|
'event_id': '\$2',
|
||||||
|
'origin_server_ts': testTimeStamp - 1000,
|
||||||
|
}),
|
||||||
|
MatrixEvent.fromJson({
|
||||||
|
'type': 'm.room.message',
|
||||||
|
'content': {'msgtype': 'm.text', 'body': 'Testcase'},
|
||||||
|
'sender': '@alice:example.com',
|
||||||
|
'status': EventStatus.synced.intValue,
|
||||||
|
'event_id': '\$1',
|
||||||
|
'origin_server_ts': testTimeStamp,
|
||||||
|
}),
|
||||||
|
MatrixEvent.fromJson({
|
||||||
|
'type': 'm.room.message',
|
||||||
|
'content': {'msgtype': 'm.text', 'body': 'Testcase'},
|
||||||
|
'sender': '@bob:example.com',
|
||||||
|
'status': EventStatus.synced.intValue,
|
||||||
|
'event_id': '\$0',
|
||||||
|
'origin_server_ts': testTimeStamp + 50,
|
||||||
|
}),
|
||||||
|
]))
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(timeline.sub != null, true);
|
||||||
|
|
||||||
|
await waitForCount(3);
|
||||||
|
|
||||||
|
expect(updateCount, 3);
|
||||||
|
expect(insertList, [0, 0, 0]);
|
||||||
|
expect(insertList.length, timeline.events.length);
|
||||||
|
expect(timeline.events[1].senderFromMemoryOrFallback.id,
|
||||||
|
'@alice:example.com');
|
||||||
|
expect(
|
||||||
|
timeline.events[0].senderFromMemoryOrFallback.id, '@bob:example.com');
|
||||||
|
expect(timeline.events[0].receipts, []);
|
||||||
|
expect(timeline.events[1].receipts, []);
|
||||||
|
expect(timeline.events[2].receipts, []);
|
||||||
|
|
||||||
|
await client.handleSync(SyncUpdate(
|
||||||
|
nextBatch: 'something',
|
||||||
|
rooms: RoomsUpdate(join: {
|
||||||
|
timeline.room.id: JoinedRoomUpdate(ephemeral: [
|
||||||
|
BasicRoomEvent.fromJson({
|
||||||
|
'type': 'm.receipt',
|
||||||
|
'content': {
|
||||||
|
'\$2': {
|
||||||
|
'm.read': {
|
||||||
|
'@alice:example.com': {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(room.receiptState.global.latestOwnReceipt?.eventId, null);
|
||||||
|
expect(room.receiptState.global.otherUsers['@alice:example.com']?.eventId,
|
||||||
|
'\$2');
|
||||||
|
expect(timeline.events[2].receipts.length, 1);
|
||||||
|
expect(timeline.events[2].receipts[0].user.id, '@alice:example.com');
|
||||||
|
|
||||||
|
await client.handleSync(SyncUpdate(
|
||||||
|
nextBatch: 'something2',
|
||||||
|
rooms: RoomsUpdate(join: {
|
||||||
|
timeline.room.id: JoinedRoomUpdate(ephemeral: [
|
||||||
|
BasicRoomEvent.fromJson({
|
||||||
|
'type': 'm.receipt',
|
||||||
|
'content': {
|
||||||
|
'\$2': {
|
||||||
|
'm.read': {
|
||||||
|
client.userID: {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
'@bob:example.com': {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(room.receiptState.global.latestOwnReceipt?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPublic?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPrivate?.eventId, null);
|
||||||
|
expect(room.receiptState.global.otherUsers['@alice:example.com']?.eventId,
|
||||||
|
'\$2');
|
||||||
|
expect(room.receiptState.global.otherUsers['@bob:example.com']?.eventId,
|
||||||
|
'\$2');
|
||||||
|
expect(timeline.events[2].receipts.length, 3);
|
||||||
|
expect(timeline.events[2].receipts[0].user.id, '@alice:example.com');
|
||||||
|
|
||||||
|
await client.handleSync(SyncUpdate(
|
||||||
|
nextBatch: 'something3',
|
||||||
|
rooms: RoomsUpdate(join: {
|
||||||
|
timeline.room.id: JoinedRoomUpdate(ephemeral: [
|
||||||
|
BasicRoomEvent.fromJson({
|
||||||
|
'type': 'm.receipt',
|
||||||
|
'content': {
|
||||||
|
'\$2': {
|
||||||
|
'm.read.private': {
|
||||||
|
client.userID: {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
'@alice:example.com': {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'm.read': {
|
||||||
|
'@bob:example.com': {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
'thread_id': '\$734'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(room.receiptState.global.latestOwnReceipt?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPublic?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPrivate?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.otherUsers['@alice:example.com']?.eventId,
|
||||||
|
'\$2');
|
||||||
|
expect(room.receiptState.global.otherUsers['@bob:example.com']?.eventId,
|
||||||
|
'\$2');
|
||||||
|
expect(room.receiptState.byThread.length, 1);
|
||||||
|
expect(timeline.events[2].receipts.length, 3);
|
||||||
|
expect(timeline.events[2].receipts[0].user.id, '@alice:example.com');
|
||||||
|
|
||||||
|
await client.handleSync(SyncUpdate(
|
||||||
|
nextBatch: 'something4',
|
||||||
|
rooms: RoomsUpdate(join: {
|
||||||
|
timeline.room.id: JoinedRoomUpdate(ephemeral: [
|
||||||
|
BasicRoomEvent.fromJson({
|
||||||
|
'type': 'm.receipt',
|
||||||
|
'content': {
|
||||||
|
'\$1': {
|
||||||
|
'm.read.private': {
|
||||||
|
client.userID: {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
'@bob:example.com': {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(room.receiptState.global.latestOwnReceipt?.eventId, '\$1');
|
||||||
|
expect(room.receiptState.global.ownPublic?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPrivate?.eventId, '\$1');
|
||||||
|
expect(room.receiptState.global.otherUsers['@alice:example.com']?.eventId,
|
||||||
|
'\$2');
|
||||||
|
expect(room.receiptState.global.otherUsers['@bob:example.com']?.eventId,
|
||||||
|
'\$1');
|
||||||
|
expect(room.receiptState.byThread.length, 1);
|
||||||
|
expect(timeline.events[1].receipts.length, 2);
|
||||||
|
expect(timeline.events[1].receipts[0].user.id, '@bob:example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sending both receipts at the same time sets the latest receipt',
|
||||||
|
() async {
|
||||||
|
await client.handleSync(SyncUpdate(
|
||||||
|
nextBatch: 'something',
|
||||||
|
rooms: RoomsUpdate(join: {
|
||||||
|
timeline.room.id: JoinedRoomUpdate(
|
||||||
|
timeline: TimelineUpdate(events: [
|
||||||
|
MatrixEvent.fromJson({
|
||||||
|
'type': 'm.room.message',
|
||||||
|
'content': {'msgtype': 'm.text', 'body': 'Testcase'},
|
||||||
|
'sender': '@alice:example.com',
|
||||||
|
'status': EventStatus.synced.intValue,
|
||||||
|
'event_id': '\$2',
|
||||||
|
'origin_server_ts': testTimeStamp - 1000,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
ephemeral: [
|
||||||
|
BasicRoomEvent.fromJson({
|
||||||
|
'type': 'm.receipt',
|
||||||
|
'content': {
|
||||||
|
'\$2': {
|
||||||
|
'm.read': {
|
||||||
|
client.userID: {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'm.read.private': {
|
||||||
|
client.userID: {
|
||||||
|
'ts': 1436451550453,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})));
|
||||||
|
|
||||||
|
expect(timeline.sub != null, true);
|
||||||
|
|
||||||
|
await waitForCount(1);
|
||||||
|
|
||||||
|
expect(room.receiptState.global.latestOwnReceipt?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPublic?.eventId, '\$2');
|
||||||
|
expect(room.receiptState.global.ownPrivate?.eventId, '\$2');
|
||||||
|
});
|
||||||
|
|
||||||
test('Send message', () async {
|
test('Send message', () async {
|
||||||
await room.sendTextEvent('test', txid: '1234');
|
await room.sendTextEvent('test', txid: '1234');
|
||||||
|
|
||||||
|
|
@ -473,8 +706,8 @@ void main() {
|
||||||
));
|
));
|
||||||
await Future.delayed(Duration(milliseconds: 50));
|
await Future.delayed(Duration(milliseconds: 50));
|
||||||
room.notificationCount = 1;
|
room.notificationCount = 1;
|
||||||
await timeline.setReadMarker();
|
await timeline.setReadMarker(null);
|
||||||
expect(room.notificationCount, 0);
|
//expect(room.notificationCount, 0);
|
||||||
});
|
});
|
||||||
test('sending an event and the http request finishes first, 0 -> 1 -> 2',
|
test('sending an event and the http request finishes first, 0 -> 1 -> 2',
|
||||||
() async {
|
() async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue