/*
* Famedly Matrix SDK
* Copyright (C) 2019, 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 .
*/
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/models/timeline_chunk.dart';
import 'package:matrix/src/room_timeline.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:matrix/src/utils/file_send_request_credentials.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:matrix/src/utils/marked_unread.dart';
import 'package:matrix/src/utils/space_child.dart';
/// max PDU size for server to accept the event with some buffer incase the server adds unsigned data f.ex age
/// https://spec.matrix.org/v1.9/client-server-api/#size-limits
const int maxPDUSize = 60000;
const String messageSendingStatusKey =
'com.famedly.famedlysdk.message_sending_status';
const String fileSendingStatusKey =
'com.famedly.famedlysdk.file_sending_status';
/// Represents a Matrix room.
class Room {
/// The full qualified Matrix ID for the room in the format '!localid:server.abc'.
final String id;
/// Membership status of the user for this room.
Membership membership;
/// The count of unread notifications.
int notificationCount;
/// The count of highlighted notifications.
int highlightCount;
/// A token that can be supplied to the from parameter of the rooms/{roomId}/messages endpoint.
String? prev_batch;
RoomSummary summary;
/// The room states are a key value store of the key (`type`,`state_key`) => State(event).
/// In a lot of cases the `state_key` might be an empty string. You **should** use the
/// methods `getState()` and `setState()` to interact with the room states.
Map> states = {};
/// Key-Value store for ephemerals.
Map ephemerals = {};
/// Key-Value store for private account data only visible for this user.
Map roomAccountData = {};
/// Queue of sending events
/// NOTE: This shouldn't be modified directly, use [sendEvent] instead. This is only used for testing.
final sendingQueue = [];
/// List of transaction IDs of events that are currently queued to be sent
final sendingQueueEventsByTxId = [];
Timer? _clearTypingIndicatorTimer;
Map toJson() => {
'id': id,
'membership': membership.toString().split('.').last,
'highlight_count': highlightCount,
'notification_count': notificationCount,
'prev_batch': prev_batch,
'summary': summary.toJson(),
'last_event': lastEvent?.toJson(),
};
factory Room.fromJson(Map json, Client client) {
final room = Room(
client: client,
id: json['id'],
membership: Membership.values.singleWhere(
(m) => m.toString() == 'Membership.${json['membership']}',
orElse: () => Membership.join,
),
notificationCount: json['notification_count'],
highlightCount: json['highlight_count'],
prev_batch: json['prev_batch'],
summary: RoomSummary.fromJson(Map.from(json['summary'])),
);
if (json['last_event'] != null) {
room.lastEvent = Event.fromJson(json['last_event'], room);
}
return room;
}
/// Flag if the room is partial, meaning not all state events have been loaded yet
bool partial = true;
/// Post-loads the room.
/// This load all the missing state events for the room from the database
/// If the room has already been loaded, this does nothing.
Future postLoad() async {
if (!partial) {
return;
}
final allStates =
await client.database.getUnimportantRoomEventStatesForRoom(
client.importantStateEvents.toList(),
this,
);
for (final state in allStates) {
setState(state);
}
await _loadThreadsFromServer();
partial = false;
}
Map threads = {};
Future _loadThreadsFromServer() async {
try {
final response = await client.getThreadRoots(id);
for (final threadEvent in response.chunk) {
final event = Event.fromMatrixEvent(threadEvent, this);
// Store thread in database
await client.database.storeThread(
id,
event,
event, // lastEvent
false, // currentUserParticipated
1, // count
client,
);
threads[event.eventId] = (await client.database.getThread(id, event.eventId, client))!;
}
} catch (e) {
Logs().w('Failed to load threads from server', e);
}
}
Future handleThreadSync(Event event) async {
// This should be called from the client's sync handling
// when a thread-related event is received
if (event.relationshipType == RelationshipTypes.thread &&
event.relationshipEventId != null) {
// Update thread metadata in database
final root = await getEventById(event.relationshipEventId!);
if (root == null) return;
await client.database.storeThread(
id,
root,
event, // update last event
event.senderId == client.userID, // currentUserParticipated
1, // increment count - should be calculated properly
client,
);
}
}
/// Returns the [Event] for the given [typeKey] and optional [stateKey].
/// If no [stateKey] is provided, it defaults to an empty string.
/// This returns either a `StrippedStateEvent` for rooms with membership
/// "invite" or a `User`/`Event`. If you need additional information like
/// the Event ID or originServerTs you need to do a type check like:
/// ```dart
/// if (state is Event) { /*...*/ }
/// ```
StrippedStateEvent? getState(String typeKey, [String stateKey = '']) =>
states[typeKey]?[stateKey];
/// Adds the [state] to this room and overwrites a state with the same
/// typeKey/stateKey key pair if there is one.
void setState(StrippedStateEvent state) {
// Ignore other non-state events
final stateKey = state.stateKey;
// For non invite rooms this is usually an Event and we should validate
// the room ID:
if (state is Event) {
final roomId = state.roomId;
if (roomId != id) {
Logs().wtf('Tried to set state event for wrong room!');
assert(roomId == id);
return;
}
}
if (stateKey == null) {
Logs().w(
'Tried to set a non state event with type "${state.type}" as state event for a room',
);
assert(stateKey != null);
return;
}
(states[state.type] ??= {})[stateKey] = state;
client.onRoomState.add((roomId: id, state: state));
}
Future