LCOV - code coverage report
Current view: top level - lib/src/models - receipts.dart (source / functions) Coverage Total Hit
Test: merged.info Lines: 94.6 % 111 105
Test Date: 2025-01-14 13:39:53 Functions: - 0 0

            Line data    Source code
       1              : /*
       2              :  *   Famedly Matrix SDK
       3              :  *   Copyright (C) 2020, 2021, 2023 Famedly GmbH
       4              :  *
       5              :  *   This program is free software: you can redistribute it and/or modify
       6              :  *   it under the terms of the GNU Affero General Public License as
       7              :  *   published by the Free Software Foundation, either version 3 of the
       8              :  *   License, or (at your option) any later version.
       9              :  *
      10              :  *   This program is distributed in the hope that it will be useful,
      11              :  *   but WITHOUT ANY WARRANTY; without even the implied warranty of
      12              :  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
      13              :  *   GNU Affero General Public License for more details.
      14              :  *
      15              :  *   You should have received a copy of the GNU Affero General Public License
      16              :  *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
      17              :  */
      18              : 
      19              : import 'package:matrix/matrix.dart';
      20              : 
      21              : // Receipts are pretty complicated nowadays. We basicaly have 3 different aspects, that we need to multiplex together:
      22              : // 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!
      23              : // 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!
      24              : // 3. Edits can make the timeline non-linear, so receipts don't match the visual order.
      25              : // 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.
      26              : //
      27              : // So how do we solve that? Users of the SDK usually do one of these operations:
      28              : // - 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)
      29              : // - Check if the current users receipt is before or after the current event
      30              : // - List users that have read up to a certain point (possibly in a specific timeline?)
      31              : //
      32              : // 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.
      33              : // 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.
      34              : // 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.
      35              : // Here is how Element solves it: https://github.com/matrix-org/matrix-js-sdk/blob/da03c3b529576a8fcde6f2c9a171fa6cca012830/src/models/read-receipt.ts#L97
      36              : // 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.
      37              : 
      38              : /// Represents a receipt.
      39              : /// This [user] has read an event at the given [time].
      40              : class Receipt {
      41              :   final User user;
      42              :   final DateTime time;
      43              : 
      44            2 :   const Receipt(this.user, this.time);
      45              : 
      46            1 :   @override
      47            1 :   bool operator ==(Object other) => (other is Receipt &&
      48            3 :       other.user == user &&
      49            5 :       other.time.millisecondsSinceEpoch == time.millisecondsSinceEpoch);
      50              : 
      51            0 :   @override
      52            0 :   int get hashCode => Object.hash(user, time);
      53              : }
      54              : 
      55              : class ReceiptData {
      56              :   int originServerTs;
      57              :   String? threadId;
      58              : 
      59            0 :   DateTime get timestamp => DateTime.fromMillisecondsSinceEpoch(originServerTs);
      60              : 
      61           33 :   ReceiptData(this.originServerTs, {this.threadId});
      62              : }
      63              : 
      64              : class ReceiptEventContent {
      65              :   Map<String, Map<ReceiptType, Map<String, ReceiptData>>> receipts;
      66           33 :   ReceiptEventContent(this.receipts);
      67              : 
      68           33 :   factory ReceiptEventContent.fromJson(Map<String, dynamic> json) {
      69              :     // Example data:
      70              :     // {
      71              :     //   "$I": {
      72              :     //     "m.read": {
      73              :     //       "@user:example.org": {
      74              :     //         "ts": 1661384801651,
      75              :     //         "thread_id": "main" // because `I` is not in a thread, but is a threaded receipt
      76              :     //       }
      77              :     //     }
      78              :     //   },
      79              :     //   "$E": {
      80              :     //     "m.read": {
      81              :     //       "@user:example.org": {
      82              :     //         "ts": 1661384801651,
      83              :     //         "thread_id": "$A" // because `E` is in Thread `A`
      84              :     //       }
      85              :     //     }
      86              :     //   },
      87              :     //   "$D": {
      88              :     //     "m.read": {
      89              :     //       "@user:example.org": {
      90              :     //         "ts": 1661384801651
      91              :     //         // no `thread_id` because the receipt is *unthreaded*
      92              :     //       }
      93              :     //     }
      94              :     //   }
      95              :     // }
      96              : 
      97           33 :     final Map<String, Map<ReceiptType, Map<String, ReceiptData>>> receipts = {};
      98           66 :     for (final eventIdEntry in json.entries) {
      99           33 :       final eventId = eventIdEntry.key;
     100           33 :       final contentForEventId = eventIdEntry.value;
     101              : 
     102           66 :       if (!eventId.startsWith('\$') || contentForEventId is! Map) continue;
     103              : 
     104           66 :       for (final receiptTypeEntry in contentForEventId.entries) {
     105           66 :         if (receiptTypeEntry.key is! String) continue;
     106              : 
     107           66 :         final receiptType = ReceiptType.values.fromString(receiptTypeEntry.key);
     108           33 :         final contentForReceiptType = receiptTypeEntry.value;
     109              : 
     110           33 :         if (receiptType == null || contentForReceiptType is! Map) continue;
     111              : 
     112           66 :         for (final userIdEntry in contentForReceiptType.entries) {
     113           33 :           final userId = userIdEntry.key;
     114           33 :           final receiptContent = userIdEntry.value;
     115              : 
     116           33 :           if (userId is! String ||
     117           33 :               !userId.isValidMatrixId ||
     118           33 :               receiptContent is! Map) {
     119              :             continue;
     120              :           }
     121              : 
     122           33 :           final ts = receiptContent['ts'];
     123           33 :           final threadId = receiptContent['thread_id'];
     124              : 
     125           34 :           if (ts is int && (threadId == null || threadId is String)) {
     126          165 :             ((receipts[eventId] ??= {})[receiptType] ??= {})[userId] =
     127           33 :                 ReceiptData(ts, threadId: threadId);
     128              :           }
     129              :         }
     130              :       }
     131              :     }
     132              : 
     133           33 :     return ReceiptEventContent(receipts);
     134              :   }
     135              : }
     136              : 
     137              : class LatestReceiptStateData {
     138              :   String eventId;
     139              :   int ts;
     140              : 
     141            3 :   DateTime get timestamp => DateTime.fromMillisecondsSinceEpoch(ts);
     142              : 
     143           33 :   LatestReceiptStateData(this.eventId, this.ts);
     144              : 
     145            2 :   factory LatestReceiptStateData.fromJson(Map<String, dynamic> json) {
     146            6 :     return LatestReceiptStateData(json['e'], json['ts']);
     147              :   }
     148              : 
     149           66 :   Map<String, dynamic> toJson() => {
     150              :         // abbreviated names, because we will store a lot of these.
     151           33 :         'e': eventId,
     152           33 :         'ts': ts,
     153              :       };
     154              : }
     155              : 
     156              : class LatestReceiptStateForTimeline {
     157              :   LatestReceiptStateData? ownPrivate;
     158              :   LatestReceiptStateData? ownPublic;
     159              :   LatestReceiptStateData? latestOwnReceipt;
     160              : 
     161              :   Map<String, LatestReceiptStateData> otherUsers;
     162              : 
     163           33 :   LatestReceiptStateForTimeline({
     164              :     required this.ownPrivate,
     165              :     required this.ownPublic,
     166              :     required this.latestOwnReceipt,
     167              :     required this.otherUsers,
     168              :   });
     169              : 
     170            1 :   factory LatestReceiptStateForTimeline.empty() =>
     171            1 :       LatestReceiptStateForTimeline(
     172              :         ownPrivate: null,
     173              :         ownPublic: null,
     174              :         latestOwnReceipt: null,
     175            1 :         otherUsers: {},
     176              :       );
     177              : 
     178           33 :   factory LatestReceiptStateForTimeline.fromJson(Map<String, dynamic> json) {
     179           33 :     final private = json['private'];
     180           33 :     final public = json['public'];
     181           33 :     final latest = json['latest'];
     182           33 :     final Map<String, dynamic>? others = json['others'];
     183              : 
     184              :     final Map<String, LatestReceiptStateData> byUser = others
     185            8 :             ?.map((k, v) => MapEntry(k, LatestReceiptStateData.fromJson(v))) ??
     186           33 :         {};
     187              : 
     188           33 :     return LatestReceiptStateForTimeline(
     189              :       ownPrivate:
     190            1 :           private != null ? LatestReceiptStateData.fromJson(private) : null,
     191              :       ownPublic:
     192            1 :           public != null ? LatestReceiptStateData.fromJson(public) : null,
     193              :       latestOwnReceipt:
     194            1 :           latest != null ? LatestReceiptStateData.fromJson(latest) : null,
     195              :       otherUsers: byUser,
     196              :     );
     197              :   }
     198              : 
     199           66 :   Map<String, dynamic> toJson() => {
     200           36 :         if (ownPrivate != null) 'private': ownPrivate!.toJson(),
     201           36 :         if (ownPublic != null) 'public': ownPublic!.toJson(),
     202           36 :         if (latestOwnReceipt != null) 'latest': latestOwnReceipt!.toJson(),
     203          198 :         'others': otherUsers.map((k, v) => MapEntry(k, v.toJson())),
     204              :       };
     205              : }
     206              : 
     207              : class LatestReceiptState {
     208              :   static const eventType = 'com.famedly.receipts_state';
     209              : 
     210              :   /// Receipts for no specific thread
     211              :   LatestReceiptStateForTimeline global;
     212              : 
     213              :   /// Receipt for the "main" thread, which is the global timeline without any thread events
     214              :   LatestReceiptStateForTimeline? mainThread;
     215              : 
     216              :   /// Receipts inside threads
     217              :   Map<String, LatestReceiptStateForTimeline> byThread;
     218              : 
     219           33 :   LatestReceiptState({
     220              :     required this.global,
     221              :     this.mainThread,
     222              :     this.byThread = const {},
     223              :   });
     224              : 
     225           33 :   factory LatestReceiptState.fromJson(Map<String, dynamic> json) {
     226           66 :     final global = json['global'] ?? <String, dynamic>{};
     227           66 :     final Map<String, dynamic> main = json['main'] ?? <String, dynamic>{};
     228           66 :     final Map<String, dynamic> byThread = json['thread'] ?? <String, dynamic>{};
     229              : 
     230           33 :     return LatestReceiptState(
     231           33 :       global: LatestReceiptStateForTimeline.fromJson(global),
     232              :       mainThread:
     233           34 :           main.isNotEmpty ? LatestReceiptStateForTimeline.fromJson(main) : null,
     234           33 :       byThread: byThread.map(
     235            3 :         (k, v) => MapEntry(k, LatestReceiptStateForTimeline.fromJson(v)),
     236              :       ),
     237              :     );
     238              :   }
     239              : 
     240           66 :   Map<String, dynamic> toJson() => {
     241           99 :         'global': global.toJson(),
     242           36 :         if (mainThread != null) 'main': mainThread!.toJson(),
     243           66 :         if (byThread.isNotEmpty)
     244            6 :           'thread': byThread.map((k, v) => MapEntry(k, v.toJson())),
     245              :       };
     246              : 
     247           33 :   Future<void> update(
     248              :     ReceiptEventContent content,
     249              :     Room room,
     250              :   ) async {
     251           33 :     final List<LatestReceiptStateForTimeline> updatedTimelines = [];
     252           66 :     final ownUserid = room.client.userID!;
     253              : 
     254           99 :     content.receipts.forEach((eventId, receiptsByType) {
     255           66 :       receiptsByType.forEach((receiptType, receiptsByUser) {
     256           66 :         receiptsByUser.forEach((user, receipt) {
     257              :           LatestReceiptStateForTimeline? timeline;
     258           33 :           final threadId = receipt.threadId;
     259           33 :           if (threadId == 'main') {
     260            2 :             timeline = (mainThread ??= LatestReceiptStateForTimeline.empty());
     261              :           } else if (threadId != null) {
     262              :             timeline =
     263            3 :                 (byThread[threadId] ??= LatestReceiptStateForTimeline.empty());
     264              :           } else {
     265           33 :             timeline = global;
     266              :           }
     267              : 
     268              :           final receiptData =
     269           66 :               LatestReceiptStateData(eventId, receipt.originServerTs);
     270           33 :           if (user == ownUserid) {
     271            1 :             if (receiptType == ReceiptType.mReadPrivate) {
     272            1 :               timeline.ownPrivate = receiptData;
     273            1 :             } else if (receiptType == ReceiptType.mRead) {
     274            1 :               timeline.ownPublic = receiptData;
     275              :             }
     276            1 :             updatedTimelines.add(timeline);
     277              :           } else {
     278           66 :             timeline.otherUsers[user] = receiptData;
     279              :           }
     280              :         });
     281              :       });
     282              :     });
     283              : 
     284              :     // set the latest receipt to the one furthest down in the timeline, or if we don't know that, the newest ts.
     285           33 :     if (updatedTimelines.isEmpty) return;
     286              : 
     287            3 :     final eventOrder = await room.client.database?.getEventIdList(room) ?? [];
     288              : 
     289            2 :     for (final timeline in updatedTimelines) {
     290            5 :       if (timeline.ownPrivate?.eventId == timeline.ownPublic?.eventId) {
     291            1 :         if (timeline.ownPrivate != null) {
     292            2 :           timeline.latestOwnReceipt = timeline.ownPrivate;
     293              :         }
     294              :         continue;
     295              :       }
     296              : 
     297            1 :       final public = timeline.ownPublic;
     298            1 :       final private = timeline.ownPrivate;
     299              : 
     300              :       if (private == null) {
     301            1 :         timeline.latestOwnReceipt = public;
     302              :       } else if (public == null) {
     303            0 :         timeline.latestOwnReceipt = private;
     304              :       } else {
     305            2 :         final privatePos = eventOrder.indexOf(private.eventId);
     306            2 :         final publicPos = eventOrder.indexOf(public.eventId);
     307              : 
     308            1 :         if (publicPos < 0 ||
     309            1 :             privatePos <= publicPos ||
     310            0 :             (privatePos < 0 && private.ts > public.ts)) {
     311            1 :           timeline.latestOwnReceipt = private;
     312              :         } else {
     313            0 :           timeline.latestOwnReceipt = public;
     314              :         }
     315              :       }
     316              :     }
     317              :   }
     318              : }
        

Generated by: LCOV version 2.0-1