Compare commits
78 Commits
0cfffff94c
...
v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c42c117a0 | |||
| d795cb717e | |||
| 1d5d1fdf86 | |||
| d795c34dab | |||
| b38f5c139f | |||
| b623f32fbf | |||
| 19fd079436 | |||
| 7d70a96533 | |||
| dce6e34289 | |||
| 881f080916 | |||
| 051687535b | |||
| 0b420933e0 | |||
| 0b3876c3f0 | |||
| 9711d45a7a | |||
| 8dcba94de7 | |||
| 226dca8c1a | |||
| ad01a7e3e3 | |||
| adde5a4134 | |||
| 9ae1807225 | |||
| e7f8446c02 | |||
| 7b05bf200c | |||
| e992cb309f | |||
| 0f138678ec | |||
| 35658e611a | |||
| 2a25cd44cf | |||
| 29053df245 | |||
| 78ad02ec80 | |||
| e3f2ef22a6 | |||
| f884e181e3 | |||
| e69d7ed0a2 | |||
| d65e11a3ea | |||
| 294d0ee02c | |||
| 6f4abebb32 | |||
| 5d83796b37 | |||
| a06c697fe3 | |||
| 5de2a8b6af | |||
| 7234f67c42 | |||
| 972f5079f9 | |||
| 27d4ed1781 | |||
| 5f074ef695 | |||
| d0f60519fd | |||
| cd7c495cb7 | |||
| 59317d45f9 | |||
| 7c2c9f978d | |||
| d540f0c2f2 | |||
| 340bbb7ca8 | |||
| 0aaffd1249 | |||
| 04be2e8c88 | |||
| 57dbe83901 | |||
| 60c5328eb0 | |||
| 189d9ca9cd | |||
| 5d797b1e66 | |||
| 2f1a40b4d9 | |||
| 02c0cd5af0 | |||
| f2a70cd137 | |||
| 8d88c25f05 | |||
| c1c5625441 | |||
| 462e800907 | |||
| faa5ee2c4f | |||
| 5dad5730ce | |||
| 5017187927 | |||
| 14e7f72bd3 | |||
| 9ef67f5788 | |||
| 79226f6ca8 | |||
| c8c0239e36 | |||
| f1be10bf8c | |||
| 18c3c9d324 | |||
| 4825fe881d | |||
| 081d20fe50 | |||
| c1a66711db | |||
| b113e78423 | |||
| 470e8aac9c | |||
| 39babfbadd | |||
| 86f7e63f65 | |||
| ecd2a71981 | |||
| 2ece9e6209 | |||
| 9310b9c305 | |||
| abad9897b8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,4 +62,4 @@ lib/i18n/*.dart
|
||||
.android
|
||||
|
||||
# Build scripts
|
||||
release/
|
||||
release-*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.kotlin_version = '1.8.21'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Security"
|
||||
"omemo": "Security",
|
||||
"profile": "Profile",
|
||||
"media": "Media"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Notifications",
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Sicherheit"
|
||||
"omemo": "Sicherheit",
|
||||
"profile": "Profil",
|
||||
"media": "Medien"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Benachrichtigungen",
|
||||
|
||||
@@ -265,14 +265,14 @@ files:
|
||||
messages:
|
||||
type: List<Message>
|
||||
deserialise: true
|
||||
# Returned by [GetPagedSharedMediaCommand]
|
||||
- name: PagedSharedMediaResultEvent
|
||||
# Returned by [GetReactionsForMessageCommand]
|
||||
- name: ReactionsForMessageResult
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
media:
|
||||
type: List<SharedMedium>
|
||||
reactions:
|
||||
type: List<ReactionGroup>
|
||||
deserialise: true
|
||||
generate_builder: true
|
||||
builder_name: "Event"
|
||||
@@ -527,9 +527,13 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
stickerPackId: String
|
||||
stickerHashKey: String
|
||||
sticker:
|
||||
type: Sticker
|
||||
deserialise: true
|
||||
recipient: String
|
||||
quotes:
|
||||
type: Message?
|
||||
deserialise: true
|
||||
- name: FetchStickerPackCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -566,6 +570,12 @@ files:
|
||||
conversationJid: String
|
||||
olderThan: bool
|
||||
timestamp: int?
|
||||
- name: GetReactionsForMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
generate_builder: true
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
||||
@@ -59,7 +59,7 @@ import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
||||
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||
//import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||
@@ -275,14 +275,16 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
conversationJid: settings.arguments! as String,
|
||||
),
|
||||
);
|
||||
case sharedMediaRoute:
|
||||
return SharedMediaPage.getRoute(
|
||||
settings.arguments! as SharedMediaPageArguments,
|
||||
);
|
||||
// case sharedMediaRoute:
|
||||
// return SharedMediaPage.getRoute(
|
||||
// settings.arguments! as SharedMediaPageArguments,
|
||||
// );
|
||||
case blocklistRoute:
|
||||
return BlocklistPage.route;
|
||||
case profileRoute:
|
||||
return ProfilePage.route;
|
||||
return ProfilePage.getRoute(
|
||||
settings.arguments! as ProfileArguments,
|
||||
);
|
||||
case settingsRoute:
|
||||
return SettingsPage.route;
|
||||
case aboutRoute:
|
||||
|
||||
@@ -93,7 +93,7 @@ class AvatarService {
|
||||
final am = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final idResult = await am.getAvatarId(jid);
|
||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||
return null;
|
||||
@@ -220,7 +220,7 @@ class AvatarService {
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.getXmppState();
|
||||
final jid = state.jid!;
|
||||
final idResult = await am.getAvatarId(jid);
|
||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.info('Error while getting latest avatar id for own avatar');
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
@@ -15,6 +16,23 @@ class BlocklistService {
|
||||
bool? _supported;
|
||||
final Logger _log = Logger('BlocklistService');
|
||||
|
||||
Future<void> _removeBlocklistEntry(String jid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
blocklistTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBlocklistEntry(String jid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
blocklistTable,
|
||||
{
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onNewConnection() {
|
||||
// Invalidate the caches
|
||||
_blocklist = null;
|
||||
@@ -49,10 +67,9 @@ class BlocklistService {
|
||||
// Diff the received blocklist with the cache
|
||||
final newItems = List<String>.empty(growable: true);
|
||||
final removedItems = List<String>.empty(growable: true);
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
for (final item in blocklist) {
|
||||
if (!_blocklist!.contains(item)) {
|
||||
await db.addBlocklistEntry(item);
|
||||
await _addBlocklistEntry(item);
|
||||
_blocklist!.add(item);
|
||||
newItems.add(item);
|
||||
}
|
||||
@@ -61,7 +78,7 @@ class BlocklistService {
|
||||
// Diff the cache with the received blocklist
|
||||
for (final item in _blocklist!) {
|
||||
if (!blocklist.contains(item)) {
|
||||
await db.removeBlocklistEntry(item);
|
||||
await _removeBlocklistEntry(item);
|
||||
_blocklist!.remove(item);
|
||||
removedItems.add(item);
|
||||
}
|
||||
@@ -83,7 +100,9 @@ class BlocklistService {
|
||||
/// Returns the blocklist from the database
|
||||
Future<List<String>> getBlocklist() async {
|
||||
if (_blocklist == null) {
|
||||
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
|
||||
final blocklistRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
|
||||
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
|
||||
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
@@ -120,7 +139,7 @@ class BlocklistService {
|
||||
_blocklist!.add(item);
|
||||
newBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
|
||||
await _addBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
case BlockPushType.unblock:
|
||||
@@ -128,7 +147,7 @@ class BlocklistService {
|
||||
_blocklist!.removeWhere((i) => i == item);
|
||||
removedBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
|
||||
await _removeBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -150,7 +169,7 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.add(jid);
|
||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(jid);
|
||||
await _addBlocklistEntry(jid);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
@@ -165,7 +184,7 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.remove(jid);
|
||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(jid);
|
||||
await _removeBlocklistEntry(jid);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
@@ -182,7 +201,8 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.clear();
|
||||
await GetIt.I.get<DatabaseService>().removeAllBlocklistEntries();
|
||||
await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
|
||||
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
@@ -122,7 +123,15 @@ class ContactsService {
|
||||
Future<Map<String, String>> _getContactIds() async {
|
||||
if (_contactIds != null) return _contactIds!;
|
||||
|
||||
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds();
|
||||
// TODO(Unknown): Can we just .cast<String, String>() here?
|
||||
_contactIds = Map<String, String>.fromEntries(
|
||||
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
|
||||
(item) => MapEntry(
|
||||
item['jid']! as String,
|
||||
item['id']! as String,
|
||||
),
|
||||
),
|
||||
);
|
||||
return _contactIds!;
|
||||
}
|
||||
|
||||
@@ -165,7 +174,6 @@ class ContactsService {
|
||||
}
|
||||
|
||||
Future<void> scanContacts() async {
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final contacts = await _fetchContactsWithJabber();
|
||||
@@ -183,7 +191,12 @@ class ContactsService {
|
||||
if (index != -1) continue;
|
||||
|
||||
final jid = knownContactIdsReverse[id]!;
|
||||
await db.removeContactId(id);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
contactsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
_contactIds!.remove(knownContactIdsReverse[id]);
|
||||
|
||||
// Remove the avatar file, if it existed
|
||||
@@ -235,7 +248,13 @@ class ContactsService {
|
||||
for (final contact in contacts) {
|
||||
// Add the ID to the cache and the database if it does not already exist
|
||||
if (!knownContactIds.containsKey(contact.jid)) {
|
||||
await db.addContactId(contact.id, contact.jid);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
contactsTable,
|
||||
<String, String>{
|
||||
'id': contact.id,
|
||||
'jid': contact.jid,
|
||||
},
|
||||
);
|
||||
_contactIds![contact.jid] = contact.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
@@ -57,13 +61,48 @@ class ConversationService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads all conversations from the database and adds them to the state and cache.
|
||||
Future<List<Conversation>> loadConversations() async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final conversationsRaw = await db.query(
|
||||
conversationsTable,
|
||||
orderBy: 'lastChangeTimestamp DESC',
|
||||
);
|
||||
|
||||
final tmp = List<Conversation>.empty(growable: true);
|
||||
for (final c in conversationsRaw) {
|
||||
final jid = c['jid']! as String;
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
|
||||
Message? lastMessage;
|
||||
if (c['lastMessageId'] != null) {
|
||||
lastMessage = await GetIt.I.get<MessageService>().getMessageById(
|
||||
c['lastMessageId']! as int,
|
||||
jid,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
}
|
||||
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
lastMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||
/// to the cache.
|
||||
Future<void> _loadConversationsIfNeeded() async {
|
||||
if (_conversationCache != null) return;
|
||||
|
||||
final conversations =
|
||||
await GetIt.I.get<DatabaseService>().loadConversations();
|
||||
final conversations = await loadConversations();
|
||||
_conversationCache = Map<String, Conversation>.fromEntries(
|
||||
conversations.map((c) => MapEntry(c.jid, c)),
|
||||
);
|
||||
@@ -87,7 +126,8 @@ class ConversationService {
|
||||
_conversationCache![conversation.jid] = conversation;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache.
|
||||
/// Updates the conversation with JID [jid] inside the database.
|
||||
///
|
||||
/// To prevent issues with the cache, only call from within
|
||||
/// [ConversationService.createOrUpdateConversation].
|
||||
Future<Conversation> updateConversation(
|
||||
@@ -103,24 +143,57 @@ class ConversationService {
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
int? sharedMediaAmount,
|
||||
}) async {
|
||||
final conversation = (await _getConversationByJid(jid))!;
|
||||
var newConversation =
|
||||
await GetIt.I.get<DatabaseService>().updateConversation(
|
||||
jid,
|
||||
lastMessage: lastMessage,
|
||||
lastChangeTimestamp: lastChangeTimestamp,
|
||||
open: open,
|
||||
unreadCounter: unreadCounter,
|
||||
avatarUrl: avatarUrl,
|
||||
chatState: conversation.chatState,
|
||||
muted: muted,
|
||||
encrypted: encrypted,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
sharedMediaAmount: sharedMediaAmount,
|
||||
|
||||
final c = <String, dynamic>{};
|
||||
|
||||
if (lastMessage != null) {
|
||||
c['lastMessageId'] = lastMessage.id;
|
||||
}
|
||||
if (lastChangeTimestamp != null) {
|
||||
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
||||
}
|
||||
if (open != null) {
|
||||
c['open'] = boolToInt(open);
|
||||
}
|
||||
if (unreadCounter != null) {
|
||||
c['unreadCounter'] = unreadCounter;
|
||||
}
|
||||
if (avatarUrl != null) {
|
||||
c['avatarUrl'] = avatarUrl;
|
||||
}
|
||||
if (muted != null) {
|
||||
c['muted'] = boolToInt(muted);
|
||||
}
|
||||
if (encrypted != null) {
|
||||
c['encrypted'] = boolToInt(encrypted);
|
||||
}
|
||||
if (contactId != notSpecified) {
|
||||
c['contactId'] = contactId as String?;
|
||||
}
|
||||
if (contactAvatarPath != notSpecified) {
|
||||
c['contactAvatarPath'] = contactAvatarPath as String?;
|
||||
}
|
||||
if (contactDisplayName != notSpecified) {
|
||||
c['contactDisplayName'] = contactDisplayName as String?;
|
||||
}
|
||||
|
||||
final result =
|
||||
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
|
||||
conversationsTable,
|
||||
c,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
var newConversation = Conversation.fromDatabaseJson(
|
||||
result,
|
||||
rosterItem != null,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
lastMessage,
|
||||
);
|
||||
|
||||
// Copy over the old lastMessage if a new one was not set
|
||||
@@ -133,8 +206,9 @@ class ConversationService {
|
||||
return newConversation;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the
|
||||
/// cache.
|
||||
/// Creates a [Conversation] inside the database given the data. This is so that the
|
||||
/// [Conversation] object can carry its database id.
|
||||
///
|
||||
/// To prevent issues with the cache, only call from within
|
||||
/// [ConversationService.createOrUpdateConversation].
|
||||
Future<Conversation> addConversationFromData(
|
||||
@@ -148,27 +222,33 @@ class ConversationService {
|
||||
bool open,
|
||||
bool muted,
|
||||
bool encrypted,
|
||||
int sharedMediaAmount,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
) async {
|
||||
final newConversation =
|
||||
await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
final newConversation = Conversation(
|
||||
title,
|
||||
lastMessage,
|
||||
type,
|
||||
avatarUrl,
|
||||
jid,
|
||||
unreadCounter,
|
||||
type,
|
||||
lastChangeTimestamp,
|
||||
open,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
muted,
|
||||
encrypted,
|
||||
sharedMediaAmount,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
ChatState.gone,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
conversationsTable,
|
||||
newConversation.toDatabaseJson(),
|
||||
);
|
||||
|
||||
if (_conversationCache != null) {
|
||||
|
||||
@@ -58,11 +58,11 @@ class CryptographyService {
|
||||
return EncryptionResult(
|
||||
key,
|
||||
iv,
|
||||
<String, String>{
|
||||
hashSha256: base64Encode(result.plaintextHash),
|
||||
<HashFunction, String>{
|
||||
HashFunction.sha256: base64Encode(result.plaintextHash),
|
||||
},
|
||||
<String, String>{
|
||||
hashSha256: base64Encode(result.ciphertextHash),
|
||||
<HashFunction, String>{
|
||||
HashFunction.sha256: base64Encode(result.ciphertextHash),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -76,8 +76,8 @@ class CryptographyService {
|
||||
SFSEncryptionType encryption,
|
||||
List<int> key,
|
||||
List<int> iv,
|
||||
Map<String, String> plaintextHashes,
|
||||
Map<String, String> ciphertextHashes,
|
||||
Map<HashFunction, String> plaintextHashes,
|
||||
Map<HashFunction, String> ciphertextHashes,
|
||||
) async {
|
||||
_log.finest('Beginning decryption for $source');
|
||||
final result = await MoxplatformPlugin.crypto.encryptFile(
|
||||
@@ -94,7 +94,7 @@ class CryptographyService {
|
||||
var passedPlaintextIntegrityCheck = true;
|
||||
var passedCiphertextIntegrityCheck = true;
|
||||
for (final entry in plaintextHashes.entries) {
|
||||
if (entry.key == hashSha256) {
|
||||
if (entry.key == HashFunction.sha256) {
|
||||
if (base64Encode(result!.plaintextHash) != entry.value) {
|
||||
passedPlaintextIntegrityCheck = false;
|
||||
} else {
|
||||
@@ -105,7 +105,7 @@ class CryptographyService {
|
||||
}
|
||||
}
|
||||
for (final entry in ciphertextHashes.entries) {
|
||||
if (entry.key == hashSha256) {
|
||||
if (entry.key == HashFunction.sha256) {
|
||||
if (base64Encode(result!.ciphertextHash) != entry.value) {
|
||||
passedCiphertextIntegrityCheck = false;
|
||||
} else {
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
|
||||
Future<List<int>> hashFileImpl(HashRequest request) async {
|
||||
final data = await File(request.path).readAsBytes();
|
||||
|
||||
return CryptographicHashManager.hashFromData(data, request.hash);
|
||||
}
|
||||
|
||||
Future<EncryptionResult> encryptFileImpl(EncryptionRequest request) async {
|
||||
Cipher algorithm;
|
||||
switch (request.encryption) {
|
||||
case SFSEncryptionType.aes128GcmNoPadding:
|
||||
algorithm = AesGcm.with128bits();
|
||||
break;
|
||||
case SFSEncryptionType.aes256GcmNoPadding:
|
||||
algorithm = AesGcm.with256bits();
|
||||
break;
|
||||
case SFSEncryptionType.aes256CbcPkcs7:
|
||||
// TODO(Unknown): Implement
|
||||
throw Exception();
|
||||
// ignore: dead_code
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate a key and an IV for the file
|
||||
final key = await algorithm.newSecretKey();
|
||||
final iv = algorithm.newNonce();
|
||||
final plaintext = await File(request.source).readAsBytes();
|
||||
final secretBox = await algorithm.encrypt(
|
||||
plaintext,
|
||||
secretKey: key,
|
||||
nonce: iv,
|
||||
);
|
||||
final ciphertext = [
|
||||
...secretBox.cipherText,
|
||||
...secretBox.mac.bytes,
|
||||
];
|
||||
|
||||
// Write the file
|
||||
await File(request.dest).writeAsBytes(ciphertext);
|
||||
|
||||
return EncryptionResult(
|
||||
await key.extractBytes(),
|
||||
iv,
|
||||
{
|
||||
hashSha256: base64Encode(
|
||||
await CryptographicHashManager.hashFromData(
|
||||
plaintext,
|
||||
HashFunction.sha256,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
hashSha256: base64Encode(
|
||||
await CryptographicHashManager.hashFromData(
|
||||
ciphertext,
|
||||
HashFunction.sha256,
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(PapaTutuWawa): Somehow fail when the ciphertext hash is not matching the provided data
|
||||
Future<DecryptionResult> decryptFileImpl(DecryptionRequest request) async {
|
||||
Cipher algorithm;
|
||||
switch (request.encryption) {
|
||||
case SFSEncryptionType.aes128GcmNoPadding:
|
||||
algorithm = AesGcm.with128bits();
|
||||
break;
|
||||
case SFSEncryptionType.aes256GcmNoPadding:
|
||||
algorithm = AesGcm.with256bits();
|
||||
break;
|
||||
case SFSEncryptionType.aes256CbcPkcs7:
|
||||
// TODO(Unknown): Implement
|
||||
throw Exception();
|
||||
// ignore: dead_code
|
||||
break;
|
||||
}
|
||||
|
||||
final ciphertextRaw = await File(request.source).readAsBytes();
|
||||
final mac = List<int>.empty(growable: true);
|
||||
final ciphertext = List<int>.empty(growable: true);
|
||||
// TODO(PapaTutuWawa): Somehow handle aes256CbcPkcs7
|
||||
if (request.encryption == SFSEncryptionType.aes128GcmNoPadding ||
|
||||
request.encryption == SFSEncryptionType.aes256GcmNoPadding) {
|
||||
mac.addAll(ciphertextRaw.sublist(ciphertextRaw.length - 16));
|
||||
ciphertext.addAll(ciphertextRaw.sublist(0, ciphertextRaw.length - 16));
|
||||
}
|
||||
|
||||
var passedCiphertextIntegrityCheck = true;
|
||||
var passedPlaintextIntegrityCheck = true;
|
||||
// Try to find one hash we can verify
|
||||
for (final entry in request.ciphertextHashes.entries) {
|
||||
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
|
||||
final hash = await CryptographicHashManager.hashFromData(
|
||||
ciphertext,
|
||||
hashFunctionFromName(entry.key),
|
||||
);
|
||||
|
||||
if (base64Encode(hash) == entry.value) {
|
||||
passedCiphertextIntegrityCheck = true;
|
||||
} else {
|
||||
passedCiphertextIntegrityCheck = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final secretBox = SecretBox(
|
||||
ciphertext,
|
||||
nonce: request.iv,
|
||||
mac: Mac(mac),
|
||||
);
|
||||
|
||||
try {
|
||||
final data = await algorithm.decrypt(
|
||||
secretBox,
|
||||
secretKey: SecretKey(request.key),
|
||||
);
|
||||
|
||||
for (final entry in request.plaintextHashes.entries) {
|
||||
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
|
||||
final hash = await CryptographicHashManager.hashFromData(
|
||||
data,
|
||||
hashFunctionFromName(entry.key),
|
||||
);
|
||||
|
||||
if (base64Encode(hash) == entry.value) {
|
||||
passedPlaintextIntegrityCheck = true;
|
||||
} else {
|
||||
passedPlaintextIntegrityCheck = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await File(request.dest).writeAsBytes(data);
|
||||
} catch (_) {
|
||||
return DecryptionResult(
|
||||
false,
|
||||
passedPlaintextIntegrityCheck,
|
||||
passedCiphertextIntegrityCheck,
|
||||
);
|
||||
}
|
||||
|
||||
return DecryptionResult(
|
||||
true,
|
||||
passedPlaintextIntegrityCheck,
|
||||
passedCiphertextIntegrityCheck,
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class EncryptionResult {
|
||||
final List<int> key;
|
||||
final List<int> iv;
|
||||
|
||||
final Map<String, String> plaintextHashes;
|
||||
final Map<String, String> ciphertextHashes;
|
||||
final Map<HashFunction, String> plaintextHashes;
|
||||
final Map<HashFunction, String> ciphertextHashes;
|
||||
}
|
||||
|
||||
@immutable
|
||||
@@ -52,13 +52,6 @@ class DecryptionRequest {
|
||||
final SFSEncryptionType encryption;
|
||||
final List<int> key;
|
||||
final List<int> iv;
|
||||
final Map<String, String> plaintextHashes;
|
||||
final Map<String, String> ciphertextHashes;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class HashRequest {
|
||||
const HashRequest(this.path, this.hash);
|
||||
final String path;
|
||||
final HashFunction hash;
|
||||
final Map<HashFunction, String> plaintextHashes;
|
||||
final Map<HashFunction, String> ciphertextHashes;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ const stickersTable = 'Stickers';
|
||||
const stickerPacksTable = 'StickerPacks';
|
||||
const blocklistTable = 'Blocklist';
|
||||
const subscriptionsTable = 'SubscriptionRequests';
|
||||
const fileMetadataTable = 'FileMetadata';
|
||||
const fileMetadataHashesTable = 'FileMetadataHashes';
|
||||
const reactionsTable = 'Reactions';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
||||
@@ -18,8 +18,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
);
|
||||
|
||||
// Messages
|
||||
await db.execute(
|
||||
'''
|
||||
await db.execute('''
|
||||
CREATE TABLE $messagesTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
@@ -27,41 +26,75 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
timestamp INTEGER NOT NULL,
|
||||
sid TEXT NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
isMedia INTEGER NOT NULL,
|
||||
isFileUploadNotification INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
errorType INTEGER,
|
||||
warningType INTEGER,
|
||||
mediaUrl TEXT,
|
||||
mediaType TEXT,
|
||||
thumbnailData TEXT,
|
||||
mediaWidth INTEGER,
|
||||
mediaHeight INTEGER,
|
||||
srcUrl TEXT,
|
||||
key TEXT,
|
||||
iv TEXT,
|
||||
encryptionScheme TEXT,
|
||||
received INTEGER,
|
||||
displayed INTEGER,
|
||||
acked INTEGER,
|
||||
originId TEXT,
|
||||
quote_id INTEGER,
|
||||
filename TEXT,
|
||||
plaintextHashes TEXT,
|
||||
ciphertextHashes TEXT,
|
||||
file_metadata_id TEXT,
|
||||
isDownloading INTEGER NOT NULL,
|
||||
isUploading INTEGER NOT NULL,
|
||||
mediaSize INTEGER,
|
||||
isRetracted INTEGER,
|
||||
isEdited INTEGER NOT NULL,
|
||||
reactions TEXT NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
stickerHashKey TEXT,
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
)''',
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||
);
|
||||
|
||||
// Reactions
|
||||
await db.execute('''
|
||||
CREATE TABLE $reactionsTable (
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||
);
|
||||
|
||||
// File metadata
|
||||
await db.execute('''
|
||||
CREATE TABLE $fileMetadataTable (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
path TEXT,
|
||||
sourceUrls TEXT,
|
||||
mimeType TEXT,
|
||||
thumbnailType TEXT,
|
||||
thumbnailData TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
plaintextHashes TEXT,
|
||||
encryptionKey TEXT,
|
||||
encryptionIv TEXT,
|
||||
encryptionScheme TEXT,
|
||||
cipherTextHashes TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER
|
||||
)''');
|
||||
await db.execute('''
|
||||
CREATE TABLE $fileMetadataHashesTable (
|
||||
algorithm TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
||||
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||
);
|
||||
|
||||
// Conversations
|
||||
@@ -78,7 +111,6 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
sharedMediaAmount INTEGER NOT NULL,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
@@ -87,6 +119,9 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute('''
|
||||
@@ -95,21 +130,6 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
jid TEXT NOT NULL
|
||||
)''');
|
||||
|
||||
// Shared media
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $mediaTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
conversation_jid TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
FOREIGN KEY (conversation_jid) REFERENCES $conversationsTable (jid),
|
||||
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
// Roster
|
||||
await db.execute(
|
||||
'''
|
||||
@@ -134,19 +154,14 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $stickersTable (
|
||||
hashKey TEXT PRIMARY KEY,
|
||||
mediaType TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
desc TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
hashes TEXT NOT NULL,
|
||||
urlSources TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
stickerPackId TEXT NOT NULL,
|
||||
suggests TEXT NOT NULL,
|
||||
file_metadata_id TEXT NOT NULL,
|
||||
stickerPackId TEXT NOT NULL,
|
||||
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||
ON DELETE CASCADE
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,3 +35,17 @@ ConversationType stringToConversationType(String type) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a map [map], extract all key-value pairs from [map] where the key starts with
|
||||
/// [prefix]. Combine those key-value pairs into a new map, where the leading [prefix]
|
||||
/// is removed from all key names.
|
||||
Map<String, T> getPrefixedSubMap<T>(Map<String, T> map, String prefix) {
|
||||
return Map<String, T>.fromEntries(
|
||||
map.entries.where((entry) => entry.key.startsWith(prefix)).map(
|
||||
(entry) => MapEntry<String, T>(
|
||||
entry.key.substring(prefix.length),
|
||||
entry.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
44
lib/service/database/migration.dart
Normal file
44
lib/service/database/migration.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A function to be called when a migration should be performed.
|
||||
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
|
||||
|
||||
/// This class represents a single database migration.
|
||||
class DatabaseMigration<T> {
|
||||
const DatabaseMigration(this.version, this.migration);
|
||||
|
||||
/// The version this migration upgrades the database to.
|
||||
final int version;
|
||||
|
||||
/// The migration callback. Called the the database version is less than [version].
|
||||
final DatabaseMigrationCallback<T> migration;
|
||||
}
|
||||
|
||||
/// Given the database [db] with the current version [version], goes through the list of
|
||||
/// migrations [migrations] and applies all migrations with a version greater than
|
||||
/// [version]. [migrations] is sorted before usage.
|
||||
///
|
||||
/// NOTE: This entire setup is written as a generic to make testing easier. We cannot easily
|
||||
/// mock, or better "instantiate", a Database object. Thus, to avoid having nullable
|
||||
/// database argument, just pass in whatever (the tests use an integer).
|
||||
Future<void> runMigrations<T>(
|
||||
Logger log,
|
||||
T db,
|
||||
List<DatabaseMigration<T>> migrations,
|
||||
int version,
|
||||
) async {
|
||||
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
|
||||
..sort(
|
||||
(a, b) => a.version.compareTo(b.version),
|
||||
);
|
||||
var currentVersion = version;
|
||||
for (final migration in sortedMigrations) {
|
||||
if (version < migration.version) {
|
||||
log.info(
|
||||
'Running database migration $currentVersion -> ${migration.version}',
|
||||
);
|
||||
await migration.migration(db);
|
||||
currentVersion = migration.version;
|
||||
}
|
||||
}
|
||||
}
|
||||
226
lib/service/database/migrations/0002_file_metadata_table.dart
Normal file
226
lib/service/database/migrations/0002_file_metadata_table.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'dart:convert';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV31ToV32(Database db) async {
|
||||
// Create the tracking table
|
||||
await db.execute('''
|
||||
CREATE TABLE $fileMetadataTable (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
path TEXT,
|
||||
sourceUrls TEXT,
|
||||
mimeType TEXT,
|
||||
thumbnailType TEXT,
|
||||
thumbnailData TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
plaintextHashes TEXT,
|
||||
encryptionKey TEXT,
|
||||
encryptionIv TEXT,
|
||||
encryptionScheme TEXT,
|
||||
cipherTextHashes TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER
|
||||
)''');
|
||||
await db.execute('''
|
||||
CREATE TABLE $fileMetadataHashesTable (
|
||||
algorithm TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
||||
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
|
||||
// Add the file_metadata_id column
|
||||
await db.execute(
|
||||
'ALTER TABLE $messagesTable ADD COLUMN file_metadata_id TEXT DEFAULT NULL;',
|
||||
);
|
||||
|
||||
// Migrate the media messages' attributes to new table
|
||||
final messages = await db.query(
|
||||
messagesTable,
|
||||
where: 'isMedia = ${boolToInt(true)}',
|
||||
);
|
||||
for (final message in messages) {
|
||||
// Do we know of a hash?
|
||||
String id;
|
||||
if (message['plaintextHashes'] != null) {
|
||||
// Plaintext hashes available (SFS)
|
||||
final plaintextHashes = deserializeHashMap(
|
||||
message['plaintextHashes']! as String,
|
||||
);
|
||||
final result = await db.query(
|
||||
fileMetadataHashesTable,
|
||||
where: 'algorithm = ? AND value = ?',
|
||||
whereArgs: [
|
||||
plaintextHashes.entries.first.key,
|
||||
plaintextHashes.entries.first.value,
|
||||
],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
final metadata = FileMetadata(
|
||||
getStrongestHashFromMap(plaintextHashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message['mediaUrl'] as String?,
|
||||
message['srcUrl'] != null ? [message['srcUrl']! as String] : null,
|
||||
message['mediaType'] as String?,
|
||||
message['mediaSize'] as int?,
|
||||
message['thumbnailData'] != null ? 'blurhash' : null,
|
||||
message['thumbnailData'] as String?,
|
||||
message['mediaWidth'] as int?,
|
||||
message['mediaHeight'] as int?,
|
||||
plaintextHashes,
|
||||
message['key'] as String?,
|
||||
message['iv'] as String?,
|
||||
message['encryptionScheme'] as String?,
|
||||
message['plaintextHashes'] == null
|
||||
? null
|
||||
: deserializeHashMap(message['ciphertextHashes']! as String),
|
||||
message['filename']! as String,
|
||||
);
|
||||
|
||||
// Create the metadata
|
||||
await db.insert(
|
||||
fileMetadataTable,
|
||||
metadata.toDatabaseJson(),
|
||||
);
|
||||
id = metadata.id;
|
||||
} else {
|
||||
id = result[0]['id']! as String;
|
||||
}
|
||||
} else {
|
||||
// No plaintext hashes are available (OOB data)
|
||||
int? size;
|
||||
int? height;
|
||||
int? width;
|
||||
Map<HashFunction, String>? hashes;
|
||||
String? filePath;
|
||||
String? urlSource;
|
||||
String? mediaType;
|
||||
String? filename;
|
||||
if (message['filename'] == null) {
|
||||
// We are dealing with a sticker
|
||||
assert(
|
||||
message['stickerPackId'] != null,
|
||||
'The message must contain a sticker',
|
||||
);
|
||||
assert(
|
||||
message['stickerHashKey'] != null,
|
||||
'The message must contain a sticker',
|
||||
);
|
||||
final sticker = (await db.query(
|
||||
stickersTable,
|
||||
where: 'stickerPackId = ? AND hashKey = ?',
|
||||
whereArgs: [message['stickerPackId'], message['stickerHashKey']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
size = sticker['size']! as int;
|
||||
width = sticker['width'] as int?;
|
||||
height = sticker['height'] as int?;
|
||||
hashes = deserializeHashMap(sticker['hashes']! as String);
|
||||
filePath = sticker['path']! as String;
|
||||
urlSource =
|
||||
((jsonDecode(sticker['urlSources']! as String) as List<dynamic>)
|
||||
.cast<String>())
|
||||
.first;
|
||||
mediaType = sticker['mediaType']! as String;
|
||||
filename = path.basename(sticker['path']! as String);
|
||||
} else {
|
||||
size = message['mediaSize'] as int?;
|
||||
width = message['mediaWidth'] as int?;
|
||||
height = message['mediaHeight'] as int?;
|
||||
filePath = message['mediaUrl'] as String?;
|
||||
urlSource = message['srcUrl'] as String?;
|
||||
mediaType = message['mediaType'] as String?;
|
||||
filename = message['filename'] as String?;
|
||||
}
|
||||
|
||||
final metadata = FileMetadata(
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
filePath,
|
||||
urlSource != null ? [urlSource] : null,
|
||||
mediaType,
|
||||
size,
|
||||
message['thumbnailData'] != null ? 'blurhash' : null,
|
||||
message['thumbnailData'] as String?,
|
||||
width,
|
||||
height,
|
||||
hashes,
|
||||
message['key'] as String?,
|
||||
message['iv'] as String?,
|
||||
message['encryptionScheme'] as String?,
|
||||
null,
|
||||
filename!,
|
||||
);
|
||||
|
||||
// Create the metadata
|
||||
await db.insert(
|
||||
fileMetadataTable,
|
||||
metadata.toDatabaseJson(),
|
||||
);
|
||||
id = metadata.id;
|
||||
}
|
||||
|
||||
// Update the message
|
||||
await db.update(
|
||||
messagesTable,
|
||||
{
|
||||
'file_metadata_id': id,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [message['id']],
|
||||
);
|
||||
}
|
||||
|
||||
// Remove columns and add foreign key
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${messagesTable}_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
body TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
sid TEXT NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
isFileUploadNotification INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
errorType INTEGER,
|
||||
warningType INTEGER,
|
||||
received INTEGER,
|
||||
displayed INTEGER,
|
||||
acked INTEGER,
|
||||
originId TEXT,
|
||||
quote_id INTEGER,
|
||||
file_metadata_id TEXT,
|
||||
isDownloading INTEGER NOT NULL,
|
||||
isUploading INTEGER NOT NULL,
|
||||
isRetracted INTEGER,
|
||||
isEdited INTEGER NOT NULL,
|
||||
reactions TEXT NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
stickerHashKey TEXT,
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO ${messagesTable}_new SELECT id, sender, body, timestamp, sid, conversationJid, isFileUploadNotification, encrypted, errorType, warningType, received, displayed, acked, originId, quote_id, file_metadata_id, isDownloading, isUploading, isRetracted, isEdited, reactions, containsNoStore, stickerPackId, stickerHashKey, pseudoMessageType, pseudoMessageData FROM $messagesTable',
|
||||
);
|
||||
await db.execute('DROP TABLE $messagesTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable;',
|
||||
);
|
||||
}
|
||||
24
lib/service/database/migrations/0002_indices.dart
Normal file
24
lib/service/database/migrations/0002_indices.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV36ToV37(Database db) async {
|
||||
// Queries against messages by id (and sid/originId happen regularly)
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||
);
|
||||
|
||||
// Conversations are often queried by their jid
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||
);
|
||||
|
||||
// Reactions must be quickly queried
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||
);
|
||||
|
||||
// File metadata should also be quickly queriable by its id
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||
);
|
||||
}
|
||||
60
lib/service/database/migrations/0002_reactions.dart
Normal file
60
lib/service/database/migrations/0002_reactions.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV34ToV35(Database db) async {
|
||||
// Create the table
|
||||
await db.execute('''
|
||||
CREATE TABLE $reactionsTable (
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
|
||||
// Figure out our JID
|
||||
final rawJid = await db.query(
|
||||
xmppStateTable,
|
||||
where: "key = 'jid'",
|
||||
limit: 1,
|
||||
);
|
||||
String? jid;
|
||||
if (rawJid.isNotEmpty) {
|
||||
jid = rawJid.first['value']! as String;
|
||||
}
|
||||
|
||||
// Migrate messages
|
||||
final messages = await db.query(
|
||||
messagesTable,
|
||||
where: "reactions IS NOT '[]'",
|
||||
);
|
||||
for (final message in messages) {
|
||||
final reactions =
|
||||
(jsonDecode(message['reactions']! as String) as List<dynamic>)
|
||||
.cast<Map<String, Object?>>();
|
||||
|
||||
for (final reaction in reactions) {
|
||||
final senders = [
|
||||
...reaction['senders']! as List<String>,
|
||||
if (intToBool(reaction['reactedBySelf']! as int) && jid != null) jid,
|
||||
];
|
||||
|
||||
for (final sender in senders) {
|
||||
await db.insert(
|
||||
reactionsTable,
|
||||
{
|
||||
'senderJid': sender,
|
||||
'emoji': reaction['emoji']! as String,
|
||||
'message_id': message['id']! as int,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the column
|
||||
await db.execute('ALTER TABLE $messagesTable DROP COLUMN reactions');
|
||||
}
|
||||
15
lib/service/database/migrations/0002_reactions_2.dart
Normal file
15
lib/service/database/migrations/0002_reactions_2.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV35ToV36(Database db) async {
|
||||
await db.execute('DROP TABLE $reactionsTable');
|
||||
await db.execute('''
|
||||
CREATE TABLE $reactionsTable (
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
}
|
||||
14
lib/service/database/migrations/0002_shared_media.dart
Normal file
14
lib/service/database/migrations/0002_shared_media.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV33ToV34(Database db) async {
|
||||
// Remove the shared media counter...
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN sharedMediaAmount',
|
||||
);
|
||||
|
||||
// ... and the entire table.
|
||||
await db.execute(
|
||||
'DROP TABLE $mediaTable',
|
||||
);
|
||||
}
|
||||
113
lib/service/database/migrations/0002_sticker_metadata.dart
Normal file
113
lib/service/database/migrations/0002_sticker_metadata.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'dart:convert';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV32ToV33(Database db) async {
|
||||
final stickers = await db.query(stickersTable);
|
||||
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${stickersTable}_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
desc TEXT NOT NULL,
|
||||
suggests TEXT NOT NULL,
|
||||
file_metadata_id TEXT NOT NULL,
|
||||
stickerPackId TEXT NOT NULL,
|
||||
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
// Mapping stickerHashKey -> fileMetadataId
|
||||
final stickerHashMap = <String, String>{};
|
||||
for (final sticker in stickers) {
|
||||
final hashes =
|
||||
(jsonDecode(sticker['hashes']! as String) as Map<String, dynamic>)
|
||||
.cast<String, String>();
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < hashes.length; i++) {
|
||||
buffer.write('(algorithm = ? AND value = ?) AND');
|
||||
}
|
||||
final query = buffer.toString();
|
||||
|
||||
final rawFm = await db.query(
|
||||
fileMetadataHashesTable,
|
||||
where: query.substring(0, query.length - 1 - 3),
|
||||
whereArgs: hashes.entries
|
||||
.map<List<String>>((entry) => [entry.key, entry.value])
|
||||
.flattened
|
||||
.toList(),
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
String fileMetadataId;
|
||||
if (rawFm.isEmpty) {
|
||||
// Create the metadata
|
||||
fileMetadataId = DateTime.now().toString();
|
||||
await db.insert(
|
||||
fileMetadataTable,
|
||||
{
|
||||
'id': fileMetadataId,
|
||||
'path': sticker['path']! as String,
|
||||
'size': sticker['size']! as int,
|
||||
'width': sticker['width'] as int?,
|
||||
'height': sticker['height'] as int?,
|
||||
'plaintextHashes': sticker['hashes']! as String,
|
||||
'mimeType': sticker['mediaType']! as String,
|
||||
'sourceUrls': sticker['urlSources'],
|
||||
'filename': path.basename(sticker['path']! as String),
|
||||
},
|
||||
);
|
||||
|
||||
// Create hash pointers
|
||||
for (final hashEntry in hashes.entries) {
|
||||
await db.insert(
|
||||
fileMetadataHashesTable,
|
||||
{
|
||||
'algorithm': hashEntry.key,
|
||||
'value': hashEntry.value,
|
||||
'id': fileMetadataId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fileMetadataId = rawFm.first['id']! as String;
|
||||
}
|
||||
|
||||
final hashKey = sticker['hashKey']! as String;
|
||||
stickerHashMap[hashKey] = fileMetadataId;
|
||||
await db.insert(
|
||||
'${stickersTable}_new',
|
||||
{
|
||||
'id': hashKey,
|
||||
'desc': sticker['desc']! as String,
|
||||
'suggests': sticker['suggests']! as String,
|
||||
'file_metadata_id': fileMetadataId,
|
||||
'stickerPackId': sticker['stickerPackId']! as String,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Rename the table
|
||||
await db.execute('DROP TABLE $stickersTable');
|
||||
await db.execute('ALTER TABLE ${stickersTable}_new RENAME TO $stickersTable');
|
||||
|
||||
// Migrate messages
|
||||
for (final stickerEntry in stickerHashMap.entries) {
|
||||
await db.update(
|
||||
messagesTable,
|
||||
{
|
||||
'file_metadata_id': stickerEntry.value,
|
||||
},
|
||||
where: 'stickerHashKey = ?',
|
||||
whereArgs: [stickerEntry.key],
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the hash key from messages
|
||||
await db.execute('ALTER TABLE $messagesTable DROP COLUMN stickerHashKey');
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/avatars.dart';
|
||||
import 'package:moxxyv2/service/blocking.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
@@ -21,6 +21,7 @@ import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
@@ -32,13 +33,14 @@ import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction_group.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
|
||||
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
//import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
void setupBackgroundEventHandler() {
|
||||
final handler = EventHandler()
|
||||
@@ -97,6 +99,7 @@ void setupBackgroundEventHandler() {
|
||||
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
||||
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
||||
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
||||
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||
@@ -151,17 +154,18 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
|
||||
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
||||
|
||||
// Check some permissions
|
||||
final storagePerm = await Permission.storage.status;
|
||||
final permissions = List<int>.empty(growable: true);
|
||||
if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
|
||||
permissions.add(Permission.storage.value);
|
||||
// TODO(Unknown): Do we still need this permission?
|
||||
// final storagePerm = await Permission.storage.status;
|
||||
// final permissions = List<int>.empty(growable: true);
|
||||
// if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
|
||||
// permissions.add(Permission.storage.value);
|
||||
|
||||
await xss.modifyXmppState(
|
||||
(state) => state.copyWith(
|
||||
askedStoragePermission: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
// await xss.modifyXmppState(
|
||||
// (state) => state.copyWith(
|
||||
// askedStoragePermission: true,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
return PreStartDoneEvent(
|
||||
state: 'logged_in',
|
||||
@@ -169,9 +173,10 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
|
||||
displayName: state.displayName ?? state.jid!.split('@').first,
|
||||
avatarUrl: state.avatarUrl,
|
||||
avatarHash: state.avatarHash,
|
||||
permissionsToRequest: permissions,
|
||||
permissionsToRequest: [],
|
||||
preferences: preferences,
|
||||
conversations: (await GetIt.I.get<DatabaseService>().loadConversations())
|
||||
conversations:
|
||||
(await GetIt.I.get<ConversationService>().loadConversations())
|
||||
.where((c) => c.open)
|
||||
.toList(),
|
||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||
@@ -240,7 +245,6 @@ Future<void> performAddConversation(
|
||||
true,
|
||||
preferences.defaultMuteState,
|
||||
preferences.enableOmemoByDefault,
|
||||
0,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(command.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
@@ -403,7 +407,9 @@ Future<void> performSetPreferences(
|
||||
final pm = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<PubSubManager>(pubsubManager)!;
|
||||
final ownJid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
final ownJid = JID.fromString(
|
||||
(await GetIt.I.get<XmppStateService>().getXmppState()).jid!,
|
||||
);
|
||||
if (command.preferences.isStickersNodePublic &&
|
||||
!oldPrefs.isStickersNodePublic) {
|
||||
// Set to open
|
||||
@@ -471,7 +477,6 @@ Future<void> performAddContact(
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
0,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
@@ -562,26 +567,33 @@ Future<void> performRequestDownload(
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: message));
|
||||
|
||||
final metadata = await peekFile(command.message.srcUrl!);
|
||||
final fileMetadata = command.message.fileMetadata!;
|
||||
final metadata = await peekFile(fileMetadata.sourceUrls!.first);
|
||||
|
||||
// TODO(Unknown): Maybe deduplicate with the code in the xmpp service
|
||||
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
|
||||
// for ".../aaaaaaaaa", in which case we would've failed anyways.
|
||||
final ext = message.srcUrl!.split('.').last;
|
||||
final ext = fileMetadata.sourceUrls!.first.split('.').last;
|
||||
final mimeGuess = metadata.mime ?? guessMimeTypeFromExtension(ext);
|
||||
|
||||
await srv.downloadFile(
|
||||
FileDownloadJob(
|
||||
MediaFileLocation(
|
||||
message.srcUrl!,
|
||||
message.filename ?? filenameFromUrl(message.srcUrl!),
|
||||
message.encryptionScheme,
|
||||
message.key != null ? base64Decode(message.key!) : null,
|
||||
message.iv != null ? base64Decode(message.iv!) : null,
|
||||
message.plaintextHashes,
|
||||
message.ciphertextHashes,
|
||||
fileMetadata.sourceUrls!,
|
||||
fileMetadata.filename,
|
||||
fileMetadata.encryptionScheme,
|
||||
fileMetadata.encryptionKey != null
|
||||
? base64Decode(fileMetadata.encryptionKey!)
|
||||
: null,
|
||||
fileMetadata.encryptionIv != null
|
||||
? base64Decode(fileMetadata.encryptionIv!)
|
||||
: null,
|
||||
fileMetadata.plaintextHashes,
|
||||
fileMetadata.ciphertextHashes,
|
||||
null,
|
||||
),
|
||||
message.id,
|
||||
message.fileMetadata!.id,
|
||||
message.conversationJid,
|
||||
mimeGuess,
|
||||
),
|
||||
@@ -655,6 +667,9 @@ Future<void> performSendChatState(
|
||||
// Only send chat states if the users wants to send them
|
||||
if (!prefs.sendChatMarkers) return;
|
||||
|
||||
// Only send chat states when we're connected
|
||||
if (!(await GetIt.I.get<ConnectivityService>().hasConnection())) return;
|
||||
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
|
||||
if (command.jid != '') {
|
||||
@@ -927,34 +942,32 @@ Future<void> performAddMessageReaction(
|
||||
AddReactionToMessageCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final msg =
|
||||
await ms.getMessageById(command.conversationJid, command.messageId);
|
||||
assert(msg != null, 'The message must be found');
|
||||
|
||||
// Update the state
|
||||
final reactions = List<Reaction>.from(msg!.reactions);
|
||||
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
|
||||
if (i == -1) {
|
||||
reactions.add(Reaction([], command.emoji, true));
|
||||
} else {
|
||||
reactions[i] = reactions[i].copyWith(reactedBySelf: true);
|
||||
final rs = GetIt.I.get<ReactionsService>();
|
||||
final msg = await rs.addNewReaction(
|
||||
command.messageId,
|
||||
command.conversationJid,
|
||||
command.emoji,
|
||||
);
|
||||
if (msg == null) {
|
||||
return;
|
||||
}
|
||||
await ms.updateMessage(msg.id, reactions: reactions);
|
||||
|
||||
// Collect all our reactions
|
||||
final ownReactions =
|
||||
reactions.where((r) => r.reactedBySelf).map((r) => r.emoji).toList();
|
||||
|
||||
if (command.conversationJid != '') {
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
|
||||
// Send the reaction
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
ownReactions,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints:
|
||||
@@ -968,35 +981,32 @@ Future<void> performRemoveMessageReaction(
|
||||
RemoveReactionFromMessageCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final msg =
|
||||
await ms.getMessageById(command.conversationJid, command.messageId);
|
||||
assert(msg != null, 'The message must be found');
|
||||
|
||||
// Update the state
|
||||
final reactions = List<Reaction>.from(msg!.reactions);
|
||||
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
|
||||
assert(i >= -1, 'The reaction must be found');
|
||||
if (reactions[i].senders.isEmpty) {
|
||||
reactions.removeAt(i);
|
||||
} else {
|
||||
reactions[i] = reactions[i].copyWith(reactedBySelf: false);
|
||||
final rs = GetIt.I.get<ReactionsService>();
|
||||
final msg = await rs.removeReaction(
|
||||
command.messageId,
|
||||
command.conversationJid,
|
||||
command.emoji,
|
||||
);
|
||||
if (msg == null) {
|
||||
return;
|
||||
}
|
||||
await ms.updateMessage(msg.id, reactions: reactions);
|
||||
|
||||
// Collect all our reactions
|
||||
final ownReactions =
|
||||
reactions.where((r) => r.reactedBySelf).map((r) => r.emoji).toList();
|
||||
|
||||
if (command.conversationJid != '') {
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
|
||||
// Send the reaction
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
ownReactions,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints:
|
||||
@@ -1042,20 +1052,12 @@ Future<void> performSendSticker(
|
||||
SendStickerCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final xs = GetIt.I.get<XmppService>();
|
||||
final ss = GetIt.I.get<StickersService>();
|
||||
|
||||
final sticker = await ss.getStickerByHashKey(
|
||||
command.stickerPackId,
|
||||
command.stickerHashKey,
|
||||
);
|
||||
assert(sticker != null, 'Sticker not found');
|
||||
|
||||
await xs.sendMessage(
|
||||
body: sticker!.desc,
|
||||
await GetIt.I.get<XmppService>().sendMessage(
|
||||
body: command.sticker.desc,
|
||||
recipients: [command.recipient],
|
||||
sticker: sticker,
|
||||
sticker: command.sticker,
|
||||
currentConversationJid: command.recipient,
|
||||
quotedMessage: command.quotes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1096,19 +1098,30 @@ Future<void> performFetchStickerPack(
|
||||
.map(
|
||||
(s) => sticker.Sticker(
|
||||
'',
|
||||
s.metadata.mediaType!,
|
||||
command.stickerPackId,
|
||||
s.metadata.desc!,
|
||||
s.metadata.size!,
|
||||
s.metadata.width,
|
||||
s.metadata.height,
|
||||
s.metadata.hashes,
|
||||
s.suggests,
|
||||
FileMetadata(
|
||||
'',
|
||||
null,
|
||||
s.sources
|
||||
.whereType<StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList(),
|
||||
'',
|
||||
command.stickerPackId,
|
||||
s.suggests,
|
||||
s.metadata.mediaType,
|
||||
s.metadata.size,
|
||||
// TODO(Unknown): One day
|
||||
null,
|
||||
null,
|
||||
s.metadata.width,
|
||||
s.metadata.height,
|
||||
s.metadata.hashes,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
s.metadata.name ?? '',
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -1188,15 +1201,49 @@ Future<void> performGetPagedSharedMedia(
|
||||
final id = extra as String;
|
||||
|
||||
final result =
|
||||
await GetIt.I.get<DatabaseService>().getPaginatedSharedMediaForJid(
|
||||
await GetIt.I.get<MessageService>().getPaginatedSharedMediaMessagesForJid(
|
||||
command.conversationJid,
|
||||
command.olderThan,
|
||||
command.timestamp,
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
PagedSharedMediaResultEvent(
|
||||
media: result,
|
||||
PagedMessagesResultEvent(
|
||||
messages: result,
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performGetReactions(
|
||||
GetReactionsForMessageCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final id = extra as String;
|
||||
|
||||
final reactionsRaw =
|
||||
await GetIt.I.get<ReactionsService>().getReactionsForMessage(
|
||||
command.messageId,
|
||||
);
|
||||
final reactionsMap = <String, List<String>>{};
|
||||
for (final reaction in reactionsRaw) {
|
||||
if (reactionsMap.containsKey(reaction.senderJid)) {
|
||||
reactionsMap[reaction.senderJid]!.add(reaction.emoji);
|
||||
} else {
|
||||
reactionsMap[reaction.senderJid] = List<String>.from([reaction.emoji]);
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
ReactionsForMessageResult(
|
||||
reactions: reactionsMap.entries
|
||||
.map(
|
||||
(entry) => ReactionGroup(
|
||||
entry.key,
|
||||
entry.value,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
|
||||
340
lib/service/files.dart
Normal file
340
lib/service/files.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// A class for returning whether a file metadata element was just created or retrieved.
|
||||
class FileMetadataWrapper {
|
||||
FileMetadataWrapper(
|
||||
this.fileMetadata,
|
||||
this.retrieved,
|
||||
);
|
||||
|
||||
/// The file metadata.
|
||||
FileMetadata fileMetadata;
|
||||
|
||||
/// Indicates whether the file metadata already exists (true) or
|
||||
/// if it has been created (false).
|
||||
bool retrieved;
|
||||
}
|
||||
|
||||
/// Returns the strongest hash from [map], if [map] is not null. If no known hash is found
|
||||
/// or [map] is null, returns null.
|
||||
String? getStrongestHashFromMap(Map<HashFunction, String>? map) {
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return map[HashFunction.blake2b512] ??
|
||||
map[HashFunction.blake2b256] ??
|
||||
map[HashFunction.sha3_512] ??
|
||||
map[HashFunction.sha3_256] ??
|
||||
map[HashFunction.sha512] ??
|
||||
map[HashFunction.sha256];
|
||||
}
|
||||
|
||||
/// Calculates the path for a given file with filename [filename] and the optional
|
||||
/// plaintext hashes [hashes]. If the base directory for the file does not exist, then it
|
||||
/// will be created.
|
||||
Future<String> computeCachedPathForFile(
|
||||
String filename,
|
||||
Map<HashFunction, String>? hashes,
|
||||
) async {
|
||||
final basePath = path.join(
|
||||
(await getApplicationDocumentsDirectory()).path,
|
||||
'media',
|
||||
);
|
||||
final baseDir = Directory(basePath);
|
||||
|
||||
if (!baseDir.existsSync()) {
|
||||
await baseDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// Keep the extension of the file. Otherwise Android will be really confused
|
||||
// as to what it should open the file with.
|
||||
final ext = path.extension(filename);
|
||||
final hash = getStrongestHashFromMap(hashes)?.replaceAll('/', '_');
|
||||
return path.join(
|
||||
basePath,
|
||||
hash != null
|
||||
? '$hash.$ext'
|
||||
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
|
||||
);
|
||||
}
|
||||
|
||||
class FilesService {
|
||||
// Logging.
|
||||
final Logger _log = Logger('FilesService');
|
||||
|
||||
Future<void> createMetadataHashEntries(
|
||||
Map<HashFunction, String> plaintextHashes,
|
||||
String metadataId,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
for (final hash in plaintextHashes.entries) {
|
||||
await db.insert(
|
||||
fileMetadataHashesTable,
|
||||
{
|
||||
'algorithm': hash.key.toName(),
|
||||
'value': hash.value,
|
||||
'id': metadataId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<FileMetadata?> getFileMetadataFromFile(FileMetadata metadata) async {
|
||||
final hash = metadata.plaintextHashes?[HashFunction.sha256] ??
|
||||
await GetIt.I
|
||||
.get<CryptographyService>()
|
||||
.hashFile(metadata.path!, HashFunction.sha256);
|
||||
final fm = await getFileMetadataFromHash({
|
||||
HashFunction.sha256: hash,
|
||||
});
|
||||
|
||||
if (fm != null) {
|
||||
return fm;
|
||||
}
|
||||
|
||||
final result = await addFileMetadataFromData(
|
||||
metadata.copyWith(
|
||||
plaintextHashes: {
|
||||
...metadata.plaintextHashes ?? {},
|
||||
HashFunction.sha256: hash,
|
||||
},
|
||||
),
|
||||
);
|
||||
await createMetadataHashEntries(result.plaintextHashes!, result.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<FileMetadata?> getFileMetadataFromHash(
|
||||
Map<HashFunction, String>? plaintextHashes,
|
||||
) async {
|
||||
if (plaintextHashes?.isEmpty ?? true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final values = List<String>.empty(growable: true);
|
||||
final query = plaintextHashes!.entries.map((entry) {
|
||||
values
|
||||
..add(entry.key.toName())
|
||||
..add(entry.value);
|
||||
return '(algorithm = ? AND value = ?)';
|
||||
}).join(' OR ');
|
||||
final hashes = await db.query(
|
||||
fileMetadataHashesTable,
|
||||
where: query,
|
||||
whereArgs: values,
|
||||
limit: 1,
|
||||
);
|
||||
if (hashes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [hashes[0]['id']! as String],
|
||||
limit: 1,
|
||||
);
|
||||
if (result.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FileMetadata.fromDatabaseJson(result[0]);
|
||||
}
|
||||
|
||||
/// Create a FileMetadata entry if we do not know the plaintext hashes described in
|
||||
/// [location].
|
||||
/// If we know of at least one hash, return that FileMetadata element.
|
||||
///
|
||||
/// If [createHashPointers] is true and we have to create a new FileMetadata element,
|
||||
/// then also create the hash pointers, if plaintext hashes are specified. If no
|
||||
/// plaintext hashes are specified or [createHashPointers] is false, no pointers will be
|
||||
/// created.
|
||||
Future<FileMetadataWrapper> createFileMetadataIfRequired(
|
||||
MediaFileLocation location,
|
||||
String? mimeType,
|
||||
int? size,
|
||||
Size? dimensions,
|
||||
String? thubnailType,
|
||||
String? thumbnailData, {
|
||||
bool createHashPointers = true,
|
||||
String? path,
|
||||
}) async {
|
||||
if (location.plaintextHashes?.isNotEmpty ?? false) {
|
||||
final result = await getFileMetadataFromHash(location.plaintextHashes);
|
||||
if (result != null) {
|
||||
_log.finest('Not creating new metadata as we found the hash');
|
||||
return FileMetadataWrapper(
|
||||
result,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final fm = FileMetadata(
|
||||
getStrongestHashFromMap(location.plaintextHashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
path,
|
||||
location.urls,
|
||||
mimeType,
|
||||
size,
|
||||
thubnailType,
|
||||
thumbnailData,
|
||||
dimensions?.width.toInt(),
|
||||
dimensions?.height.toInt(),
|
||||
location.plaintextHashes,
|
||||
location.key != null ? base64Encode(location.key!) : null,
|
||||
location.iv != null ? base64Encode(location.iv!) : null,
|
||||
location.encryptionScheme,
|
||||
location.ciphertextHashes,
|
||||
location.filename,
|
||||
);
|
||||
await db.insert(fileMetadataTable, fm.toDatabaseJson());
|
||||
|
||||
if ((location.plaintextHashes?.isNotEmpty ?? false) && createHashPointers) {
|
||||
await createMetadataHashEntries(
|
||||
location.plaintextHashes!,
|
||||
fm.id,
|
||||
);
|
||||
}
|
||||
|
||||
return FileMetadataWrapper(
|
||||
fm,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeFileMetadata(String id) async {
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileMetadata> updateFileMetadata(
|
||||
String id, {
|
||||
Object? path = notSpecified,
|
||||
int? size,
|
||||
String? encryptionScheme,
|
||||
String? encryptionKey,
|
||||
String? encryptionIv,
|
||||
List<String>? sourceUrls,
|
||||
int? width,
|
||||
int? height,
|
||||
String? mimeType,
|
||||
Map<String, String>? plaintextHashes,
|
||||
Map<String, String>? ciphertextHashes,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final m = <String, dynamic>{};
|
||||
|
||||
if (path != notSpecified) {
|
||||
m['path'] = path as String?;
|
||||
}
|
||||
if (encryptionScheme != null) {
|
||||
m['encryptionScheme'] = encryptionScheme;
|
||||
}
|
||||
if (size != null) {
|
||||
m['size'] = size;
|
||||
}
|
||||
if (encryptionKey != null) {
|
||||
m['encryptionKey'] = encryptionKey;
|
||||
}
|
||||
if (encryptionIv != null) {
|
||||
m['encryptionIv'] = encryptionIv;
|
||||
}
|
||||
if (sourceUrls != null) {
|
||||
m['sourceUrl'] = jsonEncode(sourceUrls);
|
||||
}
|
||||
if (width != null) {
|
||||
m['width'] = width;
|
||||
}
|
||||
if (height != null) {
|
||||
m['height'] = height;
|
||||
}
|
||||
if (mimeType != null) {
|
||||
m['mimeType'] = mimeType;
|
||||
}
|
||||
if (plaintextHashes != null) {
|
||||
m['plaintextHashes'] = jsonEncode(plaintextHashes);
|
||||
}
|
||||
if (ciphertextHashes != null) {
|
||||
m['cipherTextHashes'] = jsonEncode(ciphertextHashes);
|
||||
}
|
||||
|
||||
final result = await db.updateAndReturn(
|
||||
fileMetadataTable,
|
||||
m,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
return FileMetadata.fromDatabaseJson(result);
|
||||
}
|
||||
|
||||
/// Removes the file metadata described by [metadata] if it is referenced by exactly 0
|
||||
/// messages and no stickers use this file. If the file is referenced by > 1 messages
|
||||
/// or a sticker, does nothing.
|
||||
Future<void> removeFileIfNotReferenced(FileMetadata metadata) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final messagesCount = await db.count(
|
||||
messagesTable,
|
||||
'file_metadata_id = ?',
|
||||
[metadata.id],
|
||||
);
|
||||
final stickersCount = await db.count(
|
||||
stickersTable,
|
||||
'file_metadata_id = ?',
|
||||
[metadata.id],
|
||||
);
|
||||
|
||||
if (messagesCount == 0 && stickersCount == 0) {
|
||||
_log.finest(
|
||||
'Removing file metadata as no stickers and no messages reference it',
|
||||
);
|
||||
await removeFileMetadata(metadata.id);
|
||||
|
||||
// Only remove the file if we have a path
|
||||
if (metadata.path != null) {
|
||||
try {
|
||||
await File(metadata.path!).delete();
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to remove file ${metadata.path!}: $ex');
|
||||
}
|
||||
} else {
|
||||
_log.info('Not removing file as there is no path associated with it');
|
||||
}
|
||||
} else {
|
||||
_log.info(
|
||||
'Not removing file as $messagesCount messages and $stickersCount stickers reference this file',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<FileMetadata> addFileMetadataFromData(
|
||||
FileMetadata metadata,
|
||||
) async {
|
||||
final result =
|
||||
await GetIt.I.get<DatabaseService>().database.insertAndReturn(
|
||||
fileMetadataTable,
|
||||
metadata.toDatabaseJson(),
|
||||
);
|
||||
return FileMetadata.fromDatabaseJson(result);
|
||||
}
|
||||
}
|
||||
@@ -76,26 +76,27 @@ String xmppErrorToTranslatableString(XmppError error) {
|
||||
return t.errors.login.unspecified;
|
||||
}
|
||||
|
||||
String getStickerHashKeyType(Map<String, String> hashes) {
|
||||
if (hashes.containsKey('blake2b-512')) {
|
||||
return 'blake2b-512';
|
||||
} else if (hashes.containsKey('blake2b-512')) {
|
||||
return 'blake2b-256';
|
||||
} else if (hashes.containsKey('sha3-512')) {
|
||||
return 'sha3-512';
|
||||
} else if (hashes.containsKey('sha3-256')) {
|
||||
return 'sha3-256';
|
||||
} else if (hashes.containsKey('sha3-256')) {
|
||||
return 'sha-512';
|
||||
} else if (hashes.containsKey('sha-256')) {
|
||||
return 'sha-256';
|
||||
HashFunction getStickerHashKeyType(Map<HashFunction, String> hashes) {
|
||||
if (hashes.containsKey(HashFunction.blake2b512)) {
|
||||
return HashFunction.blake2b512;
|
||||
} else if (hashes.containsKey(HashFunction.blake2b256)) {
|
||||
return HashFunction.blake2b256;
|
||||
} else if (hashes.containsKey(HashFunction.sha3_512)) {
|
||||
return HashFunction.sha3_512;
|
||||
} else if (hashes.containsKey(HashFunction.sha3_256)) {
|
||||
return HashFunction.sha3_256;
|
||||
} else if (hashes.containsKey(HashFunction.sha512)) {
|
||||
return HashFunction.sha512;
|
||||
} else if (hashes.containsKey(HashFunction.sha256)) {
|
||||
return HashFunction.sha256;
|
||||
}
|
||||
|
||||
assert(false, 'No valid hash found');
|
||||
return '';
|
||||
return HashFunction.sha256;
|
||||
}
|
||||
|
||||
String getStickerHashKey(Map<String, String> hashes) {
|
||||
// TODO(PapaTutuWawa): Replace with getStrongestHash
|
||||
String getStickerHashKey(Map<HashFunction, String> hashes) {
|
||||
final key = getStickerHashKeyType(hashes);
|
||||
return '$key:${hashes[key]}';
|
||||
}
|
||||
@@ -131,16 +132,19 @@ String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
||||
if (quotedMessage.isMedia) {
|
||||
// Create formatted size string, if size is stored
|
||||
String quoteMessageSize;
|
||||
if (quotedMessage.mediaSize != null && quotedMessage.mediaSize! > 0) {
|
||||
quoteMessageSize = '(${fileSizeToString(quotedMessage.mediaSize!)}) ';
|
||||
if (quotedMessage.fileMetadata!.size != null &&
|
||||
quotedMessage.fileMetadata!.size! > 0) {
|
||||
quoteMessageSize =
|
||||
'(${fileSizeToString(quotedMessage.fileMetadata!.size!)}) ';
|
||||
} else {
|
||||
quoteMessageSize = '';
|
||||
}
|
||||
|
||||
// Create media url string, or use body if no srcUrl is stored
|
||||
String quotedMediaUrl;
|
||||
if (quotedMessage.srcUrl != null && quotedMessage.srcUrl!.isNotEmpty) {
|
||||
quotedMediaUrl = '• ${quotedMessage.srcUrl!}';
|
||||
if (quotedMessage.fileMetadata!.sourceUrls != null &&
|
||||
quotedMessage.fileMetadata!.sourceUrls!.first.isNotEmpty) {
|
||||
quotedMediaUrl = '• ${quotedMessage.fileMetadata!.sourceUrls!.first}';
|
||||
} else if (quotedMessage.body.isNotEmpty) {
|
||||
quotedMediaUrl = '• ${quotedMessage.body}';
|
||||
} else {
|
||||
|
||||
@@ -1,47 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// Calculates the path for a given file to be saved to and, if neccessary, create it.
|
||||
Future<String> getDownloadPath(
|
||||
String filename,
|
||||
String conversationJid,
|
||||
String? mime,
|
||||
) async {
|
||||
String type;
|
||||
var prependMoxxy = true;
|
||||
if (mime != null && ['image/', 'video/'].any((e) => mime.startsWith(e))) {
|
||||
type = ExternalPath.DIRECTORY_PICTURES;
|
||||
} else {
|
||||
type = ExternalPath.DIRECTORY_DOWNLOADS;
|
||||
prependMoxxy = false;
|
||||
}
|
||||
|
||||
final externalDir =
|
||||
await ExternalPath.getExternalStoragePublicDirectory(type);
|
||||
final fileDirectory = prependMoxxy
|
||||
? path.join(externalDir, 'Moxxy', conversationJid)
|
||||
: externalDir;
|
||||
final dir = Directory(fileDirectory);
|
||||
if (!dir.existsSync()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
while (true) {
|
||||
final filenameSuffix = i == 0 ? '' : '($i)';
|
||||
final suffixedFilename = filenameWithSuffix(filename, filenameSuffix);
|
||||
|
||||
final filePath = path.join(fileDirectory, suffixedFilename);
|
||||
if (!File(filePath).existsSync()) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the request was successful based on [statusCode].
|
||||
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||
@@ -49,8 +6,8 @@ bool isRequestOkay(int? statusCode) {
|
||||
return statusCode != null && statusCode >= 200 && statusCode <= 399;
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
const FileMetadata({this.mime, this.size});
|
||||
class FileUploadMetadata {
|
||||
const FileUploadMetadata({this.mime, this.size});
|
||||
final String? mime;
|
||||
final int? size;
|
||||
}
|
||||
@@ -58,10 +15,10 @@ class FileMetadata {
|
||||
/// Returns the size of the file at [url] in octets. If an error occurs or the server
|
||||
/// does not specify the Content-Length header, null is returned.
|
||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||
Future<FileMetadata> peekFile(String url) async {
|
||||
Future<FileUploadMetadata> peekFile(String url) async {
|
||||
final result = await peekUrl(Uri.parse(url));
|
||||
|
||||
return FileMetadata(
|
||||
return FileUploadMetadata(
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
@@ -13,17 +12,17 @@ import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -122,29 +121,25 @@ class HttpFileTransferService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
for (final recipient in job.recipients) {
|
||||
final newPath = await getDownloadPath(
|
||||
pathlib.basename(job.path),
|
||||
recipient,
|
||||
job.mime,
|
||||
);
|
||||
|
||||
await File(job.path).copy(newPath);
|
||||
Future<void> _copyFile(
|
||||
FileUploadJob job,
|
||||
String to,
|
||||
) async {
|
||||
if (!File(to).existsSync()) {
|
||||
await File(job.path).copy(to);
|
||||
|
||||
// Let the media scanner index the file
|
||||
MoxplatformPlugin.media.scanFile(newPath);
|
||||
|
||||
// Update the message
|
||||
await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaUrl: newPath,
|
||||
MoxplatformPlugin.media.scanFile(to);
|
||||
} else {
|
||||
_log.finest(
|
||||
'Skipping file copy on upload as file is already at media location',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
// Notify UI of upload failure
|
||||
for (final recipient in job.recipients) {
|
||||
@@ -154,6 +149,19 @@ class HttpFileTransferService {
|
||||
isUploading: false,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Update the conversation list
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
if (conversation?.lastMessage?.id == msg.id) {
|
||||
final newConversation = conversation!.copyWith(
|
||||
lastMessage: msg,
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
cs.setConversation(newConversation);
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||
}
|
||||
}
|
||||
|
||||
await _pickNextUploadTask();
|
||||
@@ -233,32 +241,10 @@ class HttpFileTransferService {
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null
|
||||
? SFSEncryptionType.aes256GcmNoPadding.toNamespace()
|
||||
: null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Get hashes
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
final plaintextHashes = <HashFunction, String>{};
|
||||
Map<HashFunction, String>? ciphertextHashes;
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
@@ -269,10 +255,11 @@ class HttpFileTransferService {
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
ciphertextHashes = encryption.ciphertextHashes;
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I
|
||||
plaintextHashes[HashFunction.sha256] = await GetIt.I
|
||||
.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
@@ -280,6 +267,76 @@ class HttpFileTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the metadata
|
||||
final filename = pathlib.basename(job.path);
|
||||
final filePath = await computeCachedPathForFile(
|
||||
filename,
|
||||
plaintextHashes,
|
||||
);
|
||||
final metadataWrapper =
|
||||
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
[slot.getUrl],
|
||||
filename,
|
||||
encryption != null
|
||||
? SFSEncryptionType.aes256GcmNoPadding.toNamespace()
|
||||
: null,
|
||||
encryption?.key,
|
||||
encryption?.iv,
|
||||
plaintextHashes,
|
||||
ciphertextHashes,
|
||||
stat.size,
|
||||
),
|
||||
job.mime,
|
||||
stat.size,
|
||||
null,
|
||||
// TODO(Unknown): job.thumbnails.first
|
||||
null,
|
||||
null,
|
||||
path: filePath,
|
||||
);
|
||||
var metadata = metadataWrapper.fileMetadata;
|
||||
|
||||
// Remove the tempoary metadata if we already know the file
|
||||
if (metadataWrapper.retrieved) {
|
||||
// Only skip the copy if the existing file metadata has a path associated with it
|
||||
if (metadataWrapper.fileMetadata.path != null) {
|
||||
_log.fine(
|
||||
'Uploaded file $filename is already tracked. Skipping copy.',
|
||||
);
|
||||
} else {
|
||||
_log.fine(
|
||||
'Uploaded file $filename is already tracked but has no path. Copying...',
|
||||
);
|
||||
await _copyFile(job, filePath);
|
||||
metadata = await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||
metadata.id,
|
||||
path: filePath,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
_log.fine('Uploaded file $filename not tracked. Copying...');
|
||||
await _copyFile(job, metadataWrapper.fileMetadata.path!);
|
||||
}
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
errorType: noError,
|
||||
isUploading: false,
|
||||
fileMetadata: metadata,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
@@ -292,7 +349,7 @@ class HttpFileTransferService {
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
name: filename,
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
@@ -305,15 +362,14 @@ class HttpFileTransferService {
|
||||
_log.finest(
|
||||
'Sent message with file upload for ${job.path} to $recipient',
|
||||
);
|
||||
|
||||
final isMultiMedia = (job.mime?.startsWith('image/') ?? false) ||
|
||||
(job.mime?.startsWith('video/') ?? false);
|
||||
if (isMultiMedia) {
|
||||
_log.finest(
|
||||
'File appears to be either an image or a video. Copying it to the correct directory...',
|
||||
);
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
|
||||
// Remove the old metadata only here because we would otherwise violate a foreign key
|
||||
// constraint.
|
||||
if (metadataWrapper.retrieved) {
|
||||
await GetIt.I.get<FilesService>().removeFileMetadata(
|
||||
job.metadataId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,8 +407,10 @@ class HttpFileTransferService {
|
||||
/// Actually attempt to download the file described by the job [job].
|
||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||
final filename = job.location.filename;
|
||||
final downloadedPath =
|
||||
await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
||||
final downloadedPath = await computeCachedPathForFile(
|
||||
job.location.filename,
|
||||
job.location.plaintextHashes,
|
||||
);
|
||||
|
||||
var downloadPath = downloadedPath;
|
||||
if (job.location.key != null && job.location.iv != null) {
|
||||
@@ -361,15 +419,18 @@ class HttpFileTransferService {
|
||||
downloadPath = pathlib.join(tempDir.path, filename);
|
||||
}
|
||||
|
||||
// TODO(Unknown): Maybe try other URLs?
|
||||
final downloadUrl = job.location.urls.first;
|
||||
_log.finest(
|
||||
'Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)',
|
||||
'Downloading $downloadUrl as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)',
|
||||
);
|
||||
|
||||
int? downloadStatusCode;
|
||||
var integrityCheckPassed = true;
|
||||
try {
|
||||
_log.finest('Beginning download...');
|
||||
downloadStatusCode = await client.downloadFile(
|
||||
Uri.parse(job.location.url),
|
||||
Uri.parse(downloadUrl),
|
||||
downloadPath,
|
||||
(total, current) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
@@ -388,18 +449,15 @@ class HttpFileTransferService {
|
||||
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.warning(
|
||||
'HTTP GET of ${job.location.url} returned $downloadStatusCode',
|
||||
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||
);
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
}
|
||||
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I
|
||||
.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable =
|
||||
job.location.key != null && job.location.iv != null;
|
||||
final crypto = GetIt.I.get<CryptographyService>();
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
@@ -409,10 +467,10 @@ class HttpFileTransferService {
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
final result = await crypto.decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
@@ -437,6 +495,28 @@ class HttpFileTransferService {
|
||||
unawaited(
|
||||
Directory(pathlib.dirname(downloadPath)).delete(recursive: true),
|
||||
);
|
||||
} else if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||
// Verify only the plaintext hash
|
||||
// TODO(Unknown): Allow verification of other hash functions
|
||||
if (job.location.plaintextHashes![HashFunction.sha256] != null) {
|
||||
final hash = await crypto.hashFile(
|
||||
downloadPath,
|
||||
HashFunction.sha256,
|
||||
);
|
||||
integrityCheckPassed =
|
||||
hash == job.location.plaintextHashes![HashFunction.sha256];
|
||||
} else if (job.location.plaintextHashes![HashFunction.sha512] != null) {
|
||||
final hash = await crypto.hashFile(
|
||||
downloadPath,
|
||||
HashFunction.sha512,
|
||||
);
|
||||
integrityCheckPassed =
|
||||
hash == job.location.plaintextHashes![HashFunction.sha512];
|
||||
} else {
|
||||
_log.warning(
|
||||
'Could not verify file integrity as no accelerated hash function is available (${job.location.plaintextHashes!.keys})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
@@ -480,17 +560,37 @@ class HttpFileTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
final fs = GetIt.I.get<FilesService>();
|
||||
final metadata = await fs.updateFileMetadata(
|
||||
job.metadataId,
|
||||
path: downloadedPath,
|
||||
size: File(downloadedPath).lengthSync(),
|
||||
width: mediaWidth,
|
||||
height: mediaHeight,
|
||||
mimeType: mime,
|
||||
);
|
||||
|
||||
// Only add the hash pointers if the file hashes match what was sent
|
||||
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||
if (integrityCheckPassed) {
|
||||
await fs.createMetadataHashEntries(
|
||||
job.location.plaintextHashes!,
|
||||
job.metadataId,
|
||||
);
|
||||
} else {
|
||||
_log.warning('Integrity check failed for file');
|
||||
}
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
fileMetadata: metadata,
|
||||
isFileUploadNotification: false,
|
||||
warningType:
|
||||
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable
|
||||
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||
? messageChatEncryptedButFileNot
|
||||
: null,
|
||||
isDownloading: false,
|
||||
@@ -498,47 +598,21 @@ class HttpFileTransferService {
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium =
|
||||
await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.jid,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
final updatedConversation = conversation.copyWith(
|
||||
lastMessage: conversation.lastMessage?.id == job.mId
|
||||
? msg
|
||||
: conversation.lastMessage,
|
||||
);
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final updatedConv = await cs.createOrUpdateConversation(
|
||||
conv.jid,
|
||||
update: (c) {
|
||||
return cs.updateConversation(
|
||||
c.jid,
|
||||
sharedMediaAmount: c.sharedMediaAmount + 1,
|
||||
);
|
||||
},
|
||||
);
|
||||
final newConv = updatedConv!.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ? msg : conv.lastMessage,
|
||||
sharedMedia: clampedListPrepend<SharedMedium>(
|
||||
conv.sharedMedia,
|
||||
sharedMedium,
|
||||
8,
|
||||
),
|
||||
);
|
||||
|
||||
_log.finest(
|
||||
'Amount of media before: ${conv.sharedMedia.length}, after: ${newConv.sharedMedia.length}',
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
cs.setConversation(updatedConversation);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) &&
|
||||
job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
await notification.showNotification(updatedConversation, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
|
||||
|
||||
// Free the download resources for the next one
|
||||
await _pickNextDownloadTask();
|
||||
|
||||
@@ -12,6 +12,7 @@ class FileUploadJob {
|
||||
this.mime,
|
||||
this.encryptMap,
|
||||
this.messageMap,
|
||||
this.metadataId,
|
||||
this.thumbnails,
|
||||
);
|
||||
final List<String> recipients;
|
||||
@@ -21,6 +22,7 @@ class FileUploadJob {
|
||||
final Map<String, bool> encryptMap;
|
||||
// Recipient -> Message
|
||||
final Map<String, Message> messageMap;
|
||||
final String metadataId;
|
||||
final List<Thumbnail> thumbnails;
|
||||
|
||||
@override
|
||||
@@ -31,7 +33,8 @@ class FileUploadJob {
|
||||
messageMap == other.messageMap &&
|
||||
mime == other.mime &&
|
||||
thumbnails == other.thumbnails &&
|
||||
encryptMap == other.encryptMap;
|
||||
encryptMap == other.encryptMap &&
|
||||
metadataId == other.metadataId;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -41,7 +44,8 @@ class FileUploadJob {
|
||||
messageMap.hashCode ^
|
||||
mime.hashCode ^
|
||||
thumbnails.hashCode ^
|
||||
encryptMap.hashCode;
|
||||
encryptMap.hashCode ^
|
||||
metadataId.hashCode;
|
||||
}
|
||||
|
||||
/// A job describing the upload of a file.
|
||||
@@ -50,12 +54,14 @@ class FileDownloadJob {
|
||||
const FileDownloadJob(
|
||||
this.location,
|
||||
this.mId,
|
||||
this.metadataId,
|
||||
this.conversationJid,
|
||||
this.mimeGuess, {
|
||||
this.shouldShowNotification = true,
|
||||
});
|
||||
final MediaFileLocation location;
|
||||
final int mId;
|
||||
final String metadataId;
|
||||
final String conversationJid;
|
||||
final String? mimeGuess;
|
||||
final bool shouldShowNotification;
|
||||
@@ -65,6 +71,7 @@ class FileDownloadJob {
|
||||
return other is FileDownloadJob &&
|
||||
location == other.location &&
|
||||
mId == other.mId &&
|
||||
metadataId == other.metadataId &&
|
||||
conversationJid == other.conversationJid &&
|
||||
mimeGuess == other.mimeGuess &&
|
||||
shouldShowNotification == other.shouldShowNotification;
|
||||
@@ -74,6 +81,7 @@ class FileDownloadJob {
|
||||
int get hashCode =>
|
||||
location.hashCode ^
|
||||
mId.hashCode ^
|
||||
metadataId.hashCode ^
|
||||
conversationJid.hashCode ^
|
||||
mimeGuess.hashCode ^
|
||||
shouldShowNotification.hashCode;
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import 'dart:convert';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
|
||||
@immutable
|
||||
class MediaFileLocation {
|
||||
const MediaFileLocation(
|
||||
this.url,
|
||||
this.urls,
|
||||
this.filename,
|
||||
this.encryptionScheme,
|
||||
this.key,
|
||||
this.iv,
|
||||
this.plaintextHashes,
|
||||
this.ciphertextHashes,
|
||||
this.size,
|
||||
);
|
||||
final String url;
|
||||
final List<String> urls;
|
||||
final String filename;
|
||||
final String? encryptionScheme;
|
||||
final List<int>? key;
|
||||
final List<int>? iv;
|
||||
final Map<String, String>? plaintextHashes;
|
||||
final Map<String, String>? ciphertextHashes;
|
||||
final Map<HashFunction, String>? plaintextHashes;
|
||||
final Map<HashFunction, String>? ciphertextHashes;
|
||||
final int? size;
|
||||
|
||||
String? get keyBase64 {
|
||||
if (key != null) return base64Encode(key!);
|
||||
@@ -34,22 +37,23 @@ class MediaFileLocation {
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
url.hashCode ^
|
||||
urls.hashCode ^
|
||||
filename.hashCode ^
|
||||
encryptionScheme.hashCode ^
|
||||
key.hashCode ^
|
||||
iv.hashCode ^
|
||||
plaintextHashes.hashCode ^
|
||||
ciphertextHashes.hashCode;
|
||||
ciphertextHashes.hashCode ^
|
||||
size.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
// TODO(PapaTutuWawa): Compare the Maps
|
||||
return other is MediaFileLocation &&
|
||||
url == other.url &&
|
||||
filename == other.filename &&
|
||||
encryptionScheme == other.encryptionScheme &&
|
||||
key == other.key &&
|
||||
iv == other.iv;
|
||||
iv == other.iv &&
|
||||
size == other.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/cache.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
@@ -23,6 +26,97 @@ class MessageService {
|
||||
LRUCache(conversationMessagePageCacheSize);
|
||||
final Lock _cacheLock = Lock();
|
||||
|
||||
Future<Message?> getMessageById(
|
||||
int id,
|
||||
String conversationJid, {
|
||||
bool queryReactionPreview = true,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final messagesRaw = await db.query(
|
||||
messagesTable,
|
||||
where: 'id = ? AND conversationJid = ?',
|
||||
whereArgs: [id, conversationJid],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (messagesRaw.isEmpty) return null;
|
||||
|
||||
// TODO(PapaTutuWawa): Load the quoted message
|
||||
final msg = messagesRaw.first;
|
||||
|
||||
// Load the file metadata, if available
|
||||
FileMetadata? fm;
|
||||
if (msg['file_metadata_id'] != null) {
|
||||
final rawFm = (await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [msg['file_metadata_id']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
fm = FileMetadata.fromDatabaseJson(rawFm);
|
||||
}
|
||||
|
||||
return Message.fromDatabaseJson(
|
||||
msg,
|
||||
null,
|
||||
fm,
|
||||
queryReactionPreview
|
||||
? await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByXmppId(
|
||||
String id,
|
||||
String conversationJid, {
|
||||
bool includeOriginId = true,
|
||||
bool queryReactionPreview = true,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final idQuery = includeOriginId ? '(sid = ? OR originId = ?)' : 'sid = ?';
|
||||
final messagesRaw = await db.query(
|
||||
messagesTable,
|
||||
where: 'conversationJid = ? AND $idQuery',
|
||||
whereArgs: [
|
||||
conversationJid,
|
||||
if (includeOriginId) id,
|
||||
id,
|
||||
],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (messagesRaw.isEmpty) return null;
|
||||
|
||||
// TODO(PapaTutuWawa): Load the quoted message
|
||||
final msg = messagesRaw.first;
|
||||
|
||||
FileMetadata? fm;
|
||||
if (msg['file_metadata_id'] != null) {
|
||||
final rawFm = (await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [msg['file_metadata_id']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
fm = FileMetadata.fromDatabaseJson(rawFm);
|
||||
}
|
||||
|
||||
return Message.fromDatabaseJson(
|
||||
msg,
|
||||
null,
|
||||
fm,
|
||||
queryReactionPreview
|
||||
? await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if
|
||||
/// specified, or the oldest messages are returned if null. If [olderThan] is false, then message must be newer
|
||||
/// than [oldestTimestamp], or the newest messages are returned if null.
|
||||
@@ -38,13 +132,109 @@ class MessageService {
|
||||
if (result != null) return result;
|
||||
}
|
||||
|
||||
final page =
|
||||
await GetIt.I.get<DatabaseService>().getPaginatedMessagesForJid(
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final query = oldestTimestamp != null
|
||||
? 'conversationJid = ? AND timestamp $comparator ?'
|
||||
: 'conversationJid = ?';
|
||||
final rawMessages = await db.rawQuery(
|
||||
// LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id
|
||||
'''
|
||||
SELECT
|
||||
msg.*,
|
||||
quote.id AS quote_id,
|
||||
quote.sender AS quote_sender,
|
||||
quote.body AS quote_body,
|
||||
quote.timestamp AS quote_timestamp,
|
||||
quote.sid AS quote_sid,
|
||||
quote.conversationJid AS quote_conversationJid,
|
||||
quote.isFileUploadNotification AS quote_isFileUploadNotification,
|
||||
quote.encrypted AS quote_encrypted,
|
||||
quote.errorType AS quote_errorType,
|
||||
quote.warningType AS quote_warningType,
|
||||
quote.received AS quote_received,
|
||||
quote.displayed AS quote_displayed,
|
||||
quote.acked AS quote_acked,
|
||||
quote.originId AS quote_originId,
|
||||
quote.quote_id AS quote_quote_id,
|
||||
quote.file_metadata_id AS quote_file_metadata_id,
|
||||
quote.isDownloading AS quote_isDownloading,
|
||||
quote.isUploading AS quote_isUploading,
|
||||
quote.isRetracted AS quote_isRetracted,
|
||||
quote.isEdited AS quote_isEdited,
|
||||
quote.containsNoStore AS quote_containsNoStore,
|
||||
quote.stickerPackId AS quote_stickerPackId,
|
||||
quote.pseudoMessageType AS quote_pseudoMessageType,
|
||||
quote.pseudoMessageData AS quote_pseudoMessageData,
|
||||
fm.id as fm_id,
|
||||
fm.path as fm_path,
|
||||
fm.sourceUrls as fm_sourceUrls,
|
||||
fm.mimeType as fm_mimeType,
|
||||
fm.thumbnailType as fm_thumbnailType,
|
||||
fm.thumbnailData as fm_thumbnailData,
|
||||
fm.width as fm_width,
|
||||
fm.height as fm_height,
|
||||
fm.plaintextHashes as fm_plaintextHashes,
|
||||
fm.encryptionKey as fm_encryptionKey,
|
||||
fm.encryptionIv as fm_encryptionIv,
|
||||
fm.encryptionScheme as fm_encryptionScheme,
|
||||
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||
fm.filename as fm_filename,
|
||||
fm.size as fm_size
|
||||
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $messagePaginationSize) AS msg
|
||||
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id
|
||||
LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id;
|
||||
''',
|
||||
[
|
||||
jid,
|
||||
olderThan,
|
||||
oldestTimestamp,
|
||||
if (oldestTimestamp != null) oldestTimestamp,
|
||||
],
|
||||
);
|
||||
|
||||
final page = List<Message>.empty(growable: true);
|
||||
for (final m in rawMessages) {
|
||||
if (m.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Message? quotes;
|
||||
if (m['quote_id'] != null) {
|
||||
final rawQuote = getPrefixedSubMap(m, 'quote_');
|
||||
|
||||
FileMetadata? quoteFm;
|
||||
if (rawQuote['file_metadata_id'] != null) {
|
||||
final rawQuoteFm = (await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [rawQuote['file_metadata_id']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
quoteFm = FileMetadata.fromDatabaseJson(rawQuoteFm);
|
||||
}
|
||||
|
||||
quotes = Message.fromDatabaseJson(rawQuote, null, quoteFm, []);
|
||||
}
|
||||
|
||||
FileMetadata? fm;
|
||||
if (m['file_metadata_id'] != null) {
|
||||
fm = FileMetadata.fromDatabaseJson(
|
||||
getPrefixedSubMap(m, 'fm_'),
|
||||
);
|
||||
}
|
||||
|
||||
page.add(
|
||||
Message.fromDatabaseJson(
|
||||
m,
|
||||
quotes,
|
||||
fm,
|
||||
await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(m['id']! as int),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (olderThan && oldestTimestamp == null) {
|
||||
await _cacheLock.synchronized(() {
|
||||
_messageCache.cache(
|
||||
@@ -57,78 +247,129 @@ class MessageService {
|
||||
return page;
|
||||
}
|
||||
|
||||
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
|
||||
/// metadata attached. This method bypasses the cache and does not load the message's
|
||||
/// quoted message, if it exists.
|
||||
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
|
||||
String jid,
|
||||
bool olderThan,
|
||||
int? oldestTimestamp,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final query = oldestTimestamp != null
|
||||
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
|
||||
final rawMessages = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
msg.*,
|
||||
fm.id as fm_id,
|
||||
fm.path as fm_path,
|
||||
fm.sourceUrls as fm_sourceUrls,
|
||||
fm.mimeType as fm_mimeType,
|
||||
fm.thumbnailType as fm_thumbnailType,
|
||||
fm.thumbnailData as fm_thumbnailData,
|
||||
fm.width as fm_width,
|
||||
fm.height as fm_height,
|
||||
fm.plaintextHashes as fm_plaintextHashes,
|
||||
fm.encryptionKey as fm_encryptionKey,
|
||||
fm.encryptionIv as fm_encryptionIv,
|
||||
fm.encryptionScheme as fm_encryptionScheme,
|
||||
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||
fm.filename as fm_filename,
|
||||
fm.size as fm_size
|
||||
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
|
||||
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
|
||||
''',
|
||||
[
|
||||
jid,
|
||||
if (oldestTimestamp != null) oldestTimestamp,
|
||||
],
|
||||
);
|
||||
|
||||
final page = List<Message>.empty(growable: true);
|
||||
for (final m in rawMessages) {
|
||||
if (m.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
page.add(
|
||||
Message.fromDatabaseJson(
|
||||
m,
|
||||
null,
|
||||
FileMetadata.fromDatabaseJson(
|
||||
getPrefixedSubMap(m, 'fm_'),
|
||||
),
|
||||
await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(m['id']! as int),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
|
||||
Future<Message> addMessageFromData(
|
||||
String body,
|
||||
int timestamp,
|
||||
String sender,
|
||||
String conversationJid,
|
||||
bool isMedia,
|
||||
String sid,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
bool containsNoStore, {
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
String? iv,
|
||||
String? encryptionScheme,
|
||||
String? mediaUrl,
|
||||
String? mediaType,
|
||||
String? thumbnailData,
|
||||
int? mediaWidth,
|
||||
int? mediaHeight,
|
||||
String? originId,
|
||||
String? quoteId,
|
||||
String? filename,
|
||||
FileMetadata? fileMetadata,
|
||||
int? errorType,
|
||||
int? warningType,
|
||||
Map<String, String>? plaintextHashes,
|
||||
Map<String, String>? ciphertextHashes,
|
||||
bool isDownloading = false,
|
||||
bool isUploading = false,
|
||||
int? mediaSize,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
bool received = false,
|
||||
bool displayed = false,
|
||||
}) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
var m = Message(
|
||||
sender,
|
||||
body,
|
||||
timestamp,
|
||||
sender,
|
||||
conversationJid,
|
||||
isMedia,
|
||||
sid,
|
||||
-1,
|
||||
conversationJid,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
containsNoStore,
|
||||
srcUrl: srcUrl,
|
||||
key: key,
|
||||
iv: iv,
|
||||
encryptionScheme: encryptionScheme,
|
||||
mediaUrl: mediaUrl,
|
||||
mediaType: mediaType,
|
||||
thumbnailData: thumbnailData,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
originId: originId,
|
||||
quoteId: quoteId,
|
||||
filename: filename,
|
||||
errorType: errorType,
|
||||
warningType: warningType,
|
||||
plaintextHashes: plaintextHashes,
|
||||
ciphertextHashes: ciphertextHashes,
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
mediaSize: mediaSize,
|
||||
stickerPackId: stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
fileMetadata: fileMetadata,
|
||||
received: received,
|
||||
displayed: displayed,
|
||||
acked: false,
|
||||
originId: originId,
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
stickerPackId: stickerPackId,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
if (quoteId != null) {
|
||||
final quotes = await getMessageByXmppId(quoteId, conversationJid);
|
||||
if (quotes == null) {
|
||||
_log.warning('Failed to add quote for message with id $quoteId');
|
||||
} else {
|
||||
m = m.copyWith(quotes: quotes);
|
||||
}
|
||||
}
|
||||
|
||||
m = m.copyWith(
|
||||
id: await db.insert(messagesTable, m.toDatabaseJson()),
|
||||
);
|
||||
|
||||
await _cacheLock.synchronized(() {
|
||||
@@ -138,21 +379,21 @@ class MessageService {
|
||||
conversationJid,
|
||||
clampedListPrepend(
|
||||
cachedList,
|
||||
msg,
|
||||
m,
|
||||
messagePaginationSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return msg;
|
||||
return m;
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByStanzaId(
|
||||
String conversationJid,
|
||||
String stanzaId,
|
||||
) async {
|
||||
return GetIt.I.get<DatabaseService>().getMessageByXmppId(
|
||||
return getMessageByXmppId(
|
||||
stanzaId,
|
||||
conversationJid,
|
||||
includeOriginId: false,
|
||||
@@ -163,14 +404,7 @@ class MessageService {
|
||||
String conversationJid,
|
||||
String id,
|
||||
) async {
|
||||
return GetIt.I.get<DatabaseService>().getMessageByXmppId(
|
||||
id,
|
||||
conversationJid,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageById(String conversationJid, int id) async {
|
||||
return GetIt.I.get<DatabaseService>().getMessageById(
|
||||
return getMessageByXmppId(
|
||||
id,
|
||||
conversationJid,
|
||||
);
|
||||
@@ -180,58 +414,101 @@ class MessageService {
|
||||
Future<Message> updateMessage(
|
||||
int id, {
|
||||
Object? body = notSpecified,
|
||||
Object? mediaUrl = notSpecified,
|
||||
Object? mediaType = notSpecified,
|
||||
bool? isMedia,
|
||||
bool? received,
|
||||
bool? displayed,
|
||||
bool? acked,
|
||||
Object? fileMetadata = notSpecified,
|
||||
Object? errorType = notSpecified,
|
||||
Object? warningType = notSpecified,
|
||||
bool? isFileUploadNotification,
|
||||
Object? srcUrl = notSpecified,
|
||||
Object? key = notSpecified,
|
||||
Object? iv = notSpecified,
|
||||
Object? encryptionScheme = notSpecified,
|
||||
Object? mediaWidth = notSpecified,
|
||||
Object? mediaHeight = notSpecified,
|
||||
Object? mediaSize = notSpecified,
|
||||
bool? isUploading,
|
||||
bool? isDownloading,
|
||||
Object? originId = notSpecified,
|
||||
Object? sid = notSpecified,
|
||||
Object? thumbnailData = notSpecified,
|
||||
bool? isRetracted,
|
||||
bool? isEdited,
|
||||
Object? reactions = notSpecified,
|
||||
}) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().updateMessage(
|
||||
id,
|
||||
body: body,
|
||||
mediaUrl: mediaUrl,
|
||||
mediaType: mediaType,
|
||||
received: received,
|
||||
displayed: displayed,
|
||||
acked: acked,
|
||||
errorType: errorType,
|
||||
warningType: warningType,
|
||||
isFileUploadNotification: isFileUploadNotification,
|
||||
srcUrl: srcUrl,
|
||||
key: key,
|
||||
iv: iv,
|
||||
encryptionScheme: encryptionScheme,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: mediaSize,
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
originId: originId,
|
||||
sid: sid,
|
||||
isRetracted: isRetracted,
|
||||
isMedia: isMedia,
|
||||
thumbnailData: thumbnailData,
|
||||
isEdited: isEdited,
|
||||
reactions: reactions,
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final m = <String, dynamic>{};
|
||||
|
||||
if (body != notSpecified) {
|
||||
m['body'] = body as String?;
|
||||
}
|
||||
if (received != null) {
|
||||
m['received'] = boolToInt(received);
|
||||
}
|
||||
if (displayed != null) {
|
||||
m['displayed'] = boolToInt(displayed);
|
||||
}
|
||||
if (acked != null) {
|
||||
m['acked'] = boolToInt(acked);
|
||||
}
|
||||
if (errorType != notSpecified) {
|
||||
m['errorType'] = errorType as int?;
|
||||
}
|
||||
if (warningType != notSpecified) {
|
||||
m['warningType'] = warningType as int?;
|
||||
}
|
||||
if (isFileUploadNotification != null) {
|
||||
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
|
||||
}
|
||||
if (isDownloading != null) {
|
||||
m['isDownloading'] = boolToInt(isDownloading);
|
||||
}
|
||||
if (isUploading != null) {
|
||||
m['isUploading'] = boolToInt(isUploading);
|
||||
}
|
||||
if (sid != notSpecified) {
|
||||
m['sid'] = sid as String?;
|
||||
}
|
||||
if (originId != notSpecified) {
|
||||
m['originId'] = originId as String?;
|
||||
}
|
||||
if (isRetracted != null) {
|
||||
m['isRetracted'] = boolToInt(isRetracted);
|
||||
}
|
||||
if (fileMetadata != notSpecified) {
|
||||
m['file_metadata_id'] = (fileMetadata as FileMetadata?)?.id;
|
||||
}
|
||||
if (isEdited != null) {
|
||||
m['isEdited'] = boolToInt(isEdited);
|
||||
}
|
||||
|
||||
final updatedMessage = await db.updateAndReturn(
|
||||
messagesTable,
|
||||
m,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
Message? quotes;
|
||||
if (updatedMessage['quote_id'] != null) {
|
||||
quotes = await getMessageById(
|
||||
updatedMessage['quote_id']! as int,
|
||||
updatedMessage['conversationJid']! as String,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
}
|
||||
|
||||
FileMetadata? metadata;
|
||||
if (fileMetadata != notSpecified) {
|
||||
metadata = fileMetadata as FileMetadata?;
|
||||
} else if (updatedMessage['file_metadata_id'] != null) {
|
||||
final metadataRaw = (await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [updatedMessage['file_metadata_id']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
metadata = FileMetadata.fromDatabaseJson(metadataRaw);
|
||||
}
|
||||
|
||||
final msg = Message.fromDatabaseJson(
|
||||
updatedMessage,
|
||||
quotes,
|
||||
metadata,
|
||||
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(id),
|
||||
);
|
||||
|
||||
await _cacheLock.synchronized(() {
|
||||
@@ -256,7 +533,6 @@ class MessageService {
|
||||
/// Helper function that manages everything related to retracting a message. It
|
||||
/// - Replaces all metadata of the message with null values and marks it as retracted
|
||||
/// - Modified the conversation, if the retracted message was the newest message
|
||||
/// - Remove the SharedMedium from the database, if one referenced the retracted message
|
||||
/// - Update the UI
|
||||
///
|
||||
/// [conversationJid] is the bare JID of the conversation this message belongs to.
|
||||
@@ -271,7 +547,7 @@ class MessageService {
|
||||
String bareSender,
|
||||
bool selfRetract,
|
||||
) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
|
||||
final msg = await getMessageByXmppId(
|
||||
originId,
|
||||
conversationJid,
|
||||
);
|
||||
@@ -294,24 +570,13 @@ class MessageService {
|
||||
}
|
||||
|
||||
final isMedia = msg.isMedia;
|
||||
final mediaUrl = msg.mediaUrl;
|
||||
final retractedMessage = await updateMessage(
|
||||
msg.id,
|
||||
isMedia: false,
|
||||
mediaUrl: null,
|
||||
mediaType: null,
|
||||
warningType: null,
|
||||
errorType: null,
|
||||
srcUrl: null,
|
||||
key: null,
|
||||
iv: null,
|
||||
encryptionScheme: null,
|
||||
mediaWidth: null,
|
||||
mediaHeight: null,
|
||||
mediaSize: null,
|
||||
isRetracted: true,
|
||||
thumbnailData: null,
|
||||
body: '',
|
||||
fileMetadata: null,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
||||
|
||||
@@ -319,40 +584,23 @@ class MessageService {
|
||||
final conversation = await cs.getConversationByJid(conversationJid);
|
||||
if (conversation != null) {
|
||||
if (conversation.lastMessage?.id == msg.id) {
|
||||
var newConversation = conversation.copyWith(
|
||||
final newConversation = conversation.copyWith(
|
||||
lastMessage: retractedMessage,
|
||||
);
|
||||
|
||||
if (isMedia) {
|
||||
await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.removeSharedMediumByMessageId(msg.id);
|
||||
|
||||
// TODO(Unknown): Technically, we would have to then load 1 shared media
|
||||
// item from the database to, if possible, fill the list
|
||||
// back up to 8 items.
|
||||
newConversation = newConversation.copyWith(
|
||||
sharedMedia:
|
||||
newConversation.sharedMedia.where((SharedMedium medium) {
|
||||
return medium.messageId != msg.id;
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
// Delete the file if we downloaded it
|
||||
if (mediaUrl != null) {
|
||||
final file = File(mediaUrl);
|
||||
if (file.existsSync()) {
|
||||
unawaited(file.delete());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cs.setConversation(newConversation);
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConversation,
|
||||
),
|
||||
);
|
||||
|
||||
if (isMedia) {
|
||||
// Remove the file
|
||||
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
|
||||
msg.fileMetadata!,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_log.warning(
|
||||
@@ -360,4 +608,22 @@ class MessageService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> replaceMessageInCache(Message message) async {
|
||||
await _cacheLock.synchronized(() {
|
||||
final cachedList = _messageCache.getValue(message.conversationJid);
|
||||
if (cachedList != null) {
|
||||
_messageCache.replaceValue(
|
||||
message.conversationJid,
|
||||
cachedList.map((m) {
|
||||
if (m.id == message.id) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return m;
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class NotificationsService {
|
||||
if (m.stickerPackId != null) {
|
||||
body = t.messages.sticker;
|
||||
} else if (m.isMedia) {
|
||||
body = mimeTypeToEmoji(m.mediaType);
|
||||
body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
|
||||
} else {
|
||||
body = m.body;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ class NotificationsService {
|
||||
? NotificationLayout.BigPicture
|
||||
: NotificationLayout.Messaging,
|
||||
category: NotificationCategory.Message,
|
||||
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null,
|
||||
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
|
||||
payload: <String, String>{
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||
@@ -16,6 +19,7 @@ import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class OmemoDoubleRatchetWrapper {
|
||||
@@ -40,21 +44,19 @@ class OmemoService {
|
||||
final done = await _lock.synchronized(() => _initialized);
|
||||
if (done) return;
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final device = await db.loadOmemoDevice(jid);
|
||||
final device = await _loadOmemoDevice(jid);
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
final deviceList = <String, List<int>>{};
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
for (final ratchet
|
||||
in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
||||
for (final ratchet in await _loadRatchets()) {
|
||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||
ratchetMap[key] = ratchet.ratchet;
|
||||
}
|
||||
|
||||
deviceList.addAll(await db.loadOmemoDeviceList());
|
||||
deviceList.addAll(await _loadOmemoDeviceList());
|
||||
}
|
||||
|
||||
final om = GetIt.I
|
||||
@@ -82,7 +84,7 @@ class OmemoService {
|
||||
|
||||
omemoManager.eventStream.listen((event) async {
|
||||
if (event is RatchetModifiedEvent) {
|
||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
||||
await _saveRatchet(
|
||||
OmemoDoubleRatchetWrapper(
|
||||
event.ratchet,
|
||||
event.deviceId,
|
||||
@@ -93,7 +95,7 @@ class OmemoService {
|
||||
if (event.added) {
|
||||
// Cache the fingerprint
|
||||
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||
await GetIt.I.get<DatabaseService>().addFingerprintsToCache([
|
||||
await _addFingerprintsToCache([
|
||||
OmemoCacheTriple(
|
||||
event.jid,
|
||||
event.deviceId,
|
||||
@@ -143,7 +145,6 @@ class OmemoService {
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
jid,
|
||||
false,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
@@ -171,7 +172,7 @@ class OmemoService {
|
||||
final oldId = await omemoManager.getDeviceId();
|
||||
|
||||
// Clear the database
|
||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
||||
await _emptyOmemoSessionTables();
|
||||
|
||||
// Regenerate the identity in the background
|
||||
final device = await compute(generateNewIdentityImpl, jid);
|
||||
@@ -228,11 +229,11 @@ class OmemoService {
|
||||
}
|
||||
|
||||
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
||||
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
|
||||
await _saveOmemoDeviceList(deviceMap);
|
||||
}
|
||||
|
||||
Future<void> commitDevice(OmemoDevice device) async {
|
||||
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
|
||||
await _saveOmemoDevice(device);
|
||||
}
|
||||
|
||||
/// Requests our device list and checks if the current device is in it. If not, then
|
||||
@@ -250,7 +251,7 @@ class OmemoService {
|
||||
final device = await omemoManager.getDevice();
|
||||
|
||||
final bundlesRaw = await dm.discoItemsQuery(
|
||||
bareJid.toString(),
|
||||
bareJid,
|
||||
node: moxxmpp.omemoBundlesXmlns,
|
||||
);
|
||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||
@@ -305,7 +306,7 @@ class OmemoService {
|
||||
_fingerprintCache[bareJid] = map;
|
||||
|
||||
// Cache them in the database
|
||||
await GetIt.I.get<DatabaseService>().addFingerprintsToCache(items);
|
||||
await _addFingerprintsToCache(items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,9 +314,7 @@ class OmemoService {
|
||||
final bareJid = jid.toBare().toString();
|
||||
if (!_fingerprintCache.containsKey(bareJid)) {
|
||||
// First try to load it from the database
|
||||
final triples = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.getFingerprintsFromCache(bareJid);
|
||||
final triples = await _getFingerprintsFromCache(bareJid);
|
||||
if (triples.isEmpty) {
|
||||
// We found no fingerprints in the database, so try to fetch them
|
||||
await _fetchFingerprintsAndCache(jid);
|
||||
@@ -361,23 +360,22 @@ class OmemoService {
|
||||
}
|
||||
|
||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
||||
await _saveTrustCache(
|
||||
json['trust']! as Map<String, int>,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
|
||||
await _saveTrustEnablementList(
|
||||
json['enable']! as Map<String, bool>,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
|
||||
await _saveTrustDeviceList(
|
||||
json['devices']! as Map<String, List<int>>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
return MoxxyBTBVTrustManager(
|
||||
await db.loadTrustCache(),
|
||||
await db.loadTrustEnablementList(),
|
||||
await db.loadTrustDeviceList(),
|
||||
await _loadTrustCache(),
|
||||
await _loadTrustEnablementList(),
|
||||
await _loadTrustDeviceList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -455,4 +453,301 @@ class OmemoService {
|
||||
omemoManager.onNewConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
|
||||
Future<List<OmemoDoubleRatchetWrapper>> _loadRatchets() async {
|
||||
final results =
|
||||
await GetIt.I.get<DatabaseService>().database.query(omemoRatchetsTable);
|
||||
|
||||
return results.map((ratchet) {
|
||||
final json = jsonDecode(ratchet['mkskipped']! as String) as List<dynamic>;
|
||||
final mkskipped = List<Map<String, dynamic>>.empty(growable: true);
|
||||
for (final i in json) {
|
||||
final element = i as Map<String, dynamic>;
|
||||
mkskipped.add({
|
||||
'key': element['key']! as String,
|
||||
'public': element['public']! as String,
|
||||
'n': element['n']! as int,
|
||||
});
|
||||
}
|
||||
|
||||
return OmemoDoubleRatchetWrapper(
|
||||
OmemoDoubleRatchet.fromJson(
|
||||
{
|
||||
...ratchet,
|
||||
'acknowledged': intToBool(ratchet['acknowledged']! as int),
|
||||
'mkskipped': mkskipped,
|
||||
},
|
||||
),
|
||||
ratchet['id']! as int,
|
||||
ratchet['jid']! as String,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
|
||||
final json = await ratchet.ratchet.toJson();
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
omemoRatchetsTable,
|
||||
{
|
||||
...json,
|
||||
'mkskipped': jsonEncode(json['mkskipped']),
|
||||
'acknowledged': boolToInt(json['acknowledged']! as bool),
|
||||
'jid': ratchet.jid,
|
||||
'id': ratchet.id,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<RatchetMapKey, BTBVTrustState>> _loadTrustCache() async {
|
||||
final entries = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoTrustCacheTable);
|
||||
|
||||
final mapEntries =
|
||||
entries.map<MapEntry<RatchetMapKey, BTBVTrustState>>((entry) {
|
||||
// TODO(PapaTutuWawa): Expose this from omemo_dart
|
||||
BTBVTrustState state;
|
||||
final value = entry['trust']! as int;
|
||||
if (value == 1) {
|
||||
state = BTBVTrustState.notTrusted;
|
||||
} else if (value == 2) {
|
||||
state = BTBVTrustState.blindTrust;
|
||||
} else if (value == 3) {
|
||||
state = BTBVTrustState.verified;
|
||||
} else {
|
||||
state = BTBVTrustState.notTrusted;
|
||||
}
|
||||
|
||||
return MapEntry(
|
||||
RatchetMapKey.fromJsonKey(entry['key']! as String),
|
||||
state,
|
||||
);
|
||||
});
|
||||
|
||||
return Map.fromEntries(mapEntries);
|
||||
}
|
||||
|
||||
Future<void> _saveTrustCache(Map<String, int> cache) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoTrustCacheTable);
|
||||
for (final entry in cache.entries) {
|
||||
batch.insert(
|
||||
omemoTrustCacheTable,
|
||||
{
|
||||
'key': entry.key,
|
||||
'trust': entry.value,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<Map<RatchetMapKey, bool>> _loadTrustEnablementList() async {
|
||||
final entries = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoTrustEnableListTable);
|
||||
|
||||
final mapEntries = entries.map<MapEntry<RatchetMapKey, bool>>((entry) {
|
||||
return MapEntry(
|
||||
RatchetMapKey.fromJsonKey(entry['key']! as String),
|
||||
intToBool(entry['enabled']! as int),
|
||||
);
|
||||
});
|
||||
|
||||
return Map.fromEntries(mapEntries);
|
||||
}
|
||||
|
||||
Future<void> _saveTrustEnablementList(Map<String, bool> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoTrustEnableListTable);
|
||||
for (final entry in list.entries) {
|
||||
batch.insert(
|
||||
omemoTrustEnableListTable,
|
||||
{
|
||||
'key': entry.key,
|
||||
'enabled': boolToInt(entry.value),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> _loadTrustDeviceList() async {
|
||||
final entries = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoTrustDeviceListTable);
|
||||
|
||||
final map = <String, List<int>>{};
|
||||
for (final entry in entries) {
|
||||
final key = entry['jid']! as String;
|
||||
final device = entry['device']! as int;
|
||||
|
||||
if (map.containsKey(key)) {
|
||||
map[key]!.add(device);
|
||||
} else {
|
||||
map[key] = [device];
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> _saveTrustDeviceList(Map<String, List<int>> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoTrustDeviceListTable);
|
||||
for (final entry in list.entries) {
|
||||
for (final device in entry.value) {
|
||||
batch.insert(
|
||||
omemoTrustDeviceListTable,
|
||||
{
|
||||
'jid': entry.key,
|
||||
'device': device,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> _saveOmemoDevice(OmemoDevice device) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
omemoDeviceTable,
|
||||
{
|
||||
'jid': device.jid,
|
||||
'id': device.id,
|
||||
'data': jsonEncode(await device.toJson()),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<OmemoDevice?> _loadOmemoDevice(String jid) async {
|
||||
final data = await GetIt.I.get<DatabaseService>().database.query(
|
||||
omemoDeviceTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
limit: 1,
|
||||
);
|
||||
if (data.isEmpty) return null;
|
||||
|
||||
final deviceJson =
|
||||
jsonDecode(data.first['data']! as String) as Map<String, dynamic>;
|
||||
// NOTE: We need to do this because Dart otherwise complains about not being able
|
||||
// to cast dynamic to List<int>.
|
||||
final opks = List<Map<String, dynamic>>.empty(growable: true);
|
||||
final opksIter = deviceJson['opks']! as List<dynamic>;
|
||||
for (final tmpOpk in opksIter) {
|
||||
final opk = tmpOpk as Map<String, dynamic>;
|
||||
opks.add(<String, dynamic>{
|
||||
'id': opk['id']! as int,
|
||||
'public': opk['public']! as String,
|
||||
'private': opk['private']! as String,
|
||||
});
|
||||
}
|
||||
deviceJson['opks'] = opks;
|
||||
return OmemoDevice.fromJson(deviceJson);
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> _loadOmemoDeviceList() async {
|
||||
final list = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoDeviceListTable);
|
||||
final map = <String, List<int>>{};
|
||||
for (final entry in list) {
|
||||
final key = entry['jid']! as String;
|
||||
final id = entry['id']! as int;
|
||||
|
||||
if (map.containsKey(key)) {
|
||||
map[key]!.add(id);
|
||||
} else {
|
||||
map[key] = [id];
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> _saveOmemoDeviceList(Map<String, List<int>> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoDeviceListTable);
|
||||
for (final entry in list.entries) {
|
||||
for (final id in entry.value) {
|
||||
batch.insert(
|
||||
omemoDeviceListTable,
|
||||
{
|
||||
'jid': entry.key,
|
||||
'id': id,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> _emptyOmemoSessionTables() async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch
|
||||
..delete(omemoRatchetsTable)
|
||||
..delete(omemoTrustCacheTable)
|
||||
..delete(omemoTrustEnableListTable);
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> _addFingerprintsToCache(List<OmemoCacheTriple> items) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
for (final item in items) {
|
||||
batch.insert(
|
||||
omemoFingerprintCache,
|
||||
<String, dynamic>{
|
||||
'jid': item.jid,
|
||||
'id': item.deviceId,
|
||||
'fingerprint': item.fingerprint,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<List<OmemoCacheTriple>> _getFingerprintsFromCache(String jid) async {
|
||||
final rawItems = await GetIt.I.get<DatabaseService>().database.query(
|
||||
omemoFingerprintCache,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
|
||||
return rawItems.map((item) {
|
||||
return OmemoCacheTriple(
|
||||
jid,
|
||||
item['id']! as int,
|
||||
item['fingerprint']! as String,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
|
||||
class PreferencesService {
|
||||
PreferencesState? _preferences;
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
_preferences = await GetIt.I.get<DatabaseService>().getPreferences();
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final preferencesRaw = (await db.query(preferenceTable)).map((preference) {
|
||||
switch (preference['type']! as int) {
|
||||
case typeInt:
|
||||
return {
|
||||
...preference,
|
||||
'value': stringToInt(preference['value']! as String),
|
||||
};
|
||||
case typeBool:
|
||||
return {
|
||||
...preference,
|
||||
'value': stringToBool(preference['value']! as String),
|
||||
};
|
||||
case typeString:
|
||||
default:
|
||||
return preference;
|
||||
}
|
||||
}).toList();
|
||||
final json = <String, dynamic>{};
|
||||
for (final preference in preferencesRaw) {
|
||||
json[preference['key']! as String] = preference['value'];
|
||||
}
|
||||
|
||||
_preferences = PreferencesState.fromJson(json);
|
||||
}
|
||||
|
||||
Future<PreferencesState> getPreferences() async {
|
||||
@@ -21,6 +46,38 @@ class PreferencesService {
|
||||
if (_preferences == null) await _loadPreferences();
|
||||
|
||||
_preferences = func(_preferences!);
|
||||
await GetIt.I.get<DatabaseService>().savePreferences(_preferences!);
|
||||
|
||||
final stateJson = _preferences!.toJson();
|
||||
final preferences = stateJson.keys.map((key) {
|
||||
int type;
|
||||
String value;
|
||||
if (stateJson[key] is int) {
|
||||
type = typeInt;
|
||||
value = intToString(stateJson[key]! as int);
|
||||
} else if (stateJson[key] is bool) {
|
||||
type = typeBool;
|
||||
value = boolToString(stateJson[key]! as bool);
|
||||
} else {
|
||||
type = typeString;
|
||||
value = stateJson[key]! as String;
|
||||
}
|
||||
|
||||
return {
|
||||
'key': key,
|
||||
'type': type,
|
||||
'value': value,
|
||||
};
|
||||
});
|
||||
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
for (final preference in preferences) {
|
||||
batch.update(
|
||||
preferenceTable,
|
||||
preference,
|
||||
where: 'key = ?',
|
||||
whereArgs: [preference['key']],
|
||||
);
|
||||
}
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
203
lib/service/reactions.dart
Normal file
203
lib/service/reactions.dart
Normal file
@@ -0,0 +1,203 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
class ReactionWrapper {
|
||||
const ReactionWrapper(this.emojis, this.modified);
|
||||
|
||||
final List<String> emojis;
|
||||
|
||||
final bool modified;
|
||||
}
|
||||
|
||||
class ReactionsService {
|
||||
final Logger _log = Logger('ReactionsService');
|
||||
|
||||
/// Query the database for 6 distinct emoji reactions associated with the message id
|
||||
/// [id].
|
||||
Future<List<String>> getPreviewReactionsForMessage(int id) async {
|
||||
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||
reactionsTable,
|
||||
where: 'message_id = ?',
|
||||
whereArgs: [id],
|
||||
columns: ['emoji'],
|
||||
distinct: true,
|
||||
limit: 6,
|
||||
);
|
||||
|
||||
return reactions.map((r) => r['emoji']! as String).toList();
|
||||
}
|
||||
|
||||
Future<List<Reaction>> getReactionsForMessage(int id) async {
|
||||
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||
reactionsTable,
|
||||
where: 'message_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
return reactions.map(Reaction.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<List<String>> getReactionsForMessageByJid(int id, String jid) async {
|
||||
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||
reactionsTable,
|
||||
where: 'message_id = ? AND senderJid = ?',
|
||||
whereArgs: [id, jid],
|
||||
);
|
||||
|
||||
return reactions.map((r) => r['emoji']! as String).toList();
|
||||
}
|
||||
|
||||
Future<int> _countReactions(int messageId, String emoji) async {
|
||||
return GetIt.I.get<DatabaseService>().database.count(
|
||||
reactionsTable,
|
||||
'message_id = ? AND emoji = ?',
|
||||
[messageId, emoji],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a new reaction [emoji], if possible, to [messageId] and returns the
|
||||
/// new message reaction preview.
|
||||
Future<Message?> addNewReaction(
|
||||
int messageId,
|
||||
String conversationJid,
|
||||
String emoji,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final msg = await ms.getMessageById(messageId, conversationJid);
|
||||
if (msg == null) {
|
||||
_log.warning('Failed to get message $messageId');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!msg.reactionsPreview.contains(emoji) &&
|
||||
msg.reactionsPreview.length < 6) {
|
||||
final newPreview = [
|
||||
...msg.reactionsPreview,
|
||||
emoji,
|
||||
];
|
||||
|
||||
try {
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
reactionsTable,
|
||||
Reaction(
|
||||
messageId,
|
||||
jid,
|
||||
emoji,
|
||||
).toJson(),
|
||||
conflictAlgorithm: ConflictAlgorithm.fail,
|
||||
);
|
||||
|
||||
final newMsg = msg.copyWith(
|
||||
reactionsPreview: newPreview,
|
||||
);
|
||||
await ms.replaceMessageInCache(newMsg);
|
||||
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: newMsg,
|
||||
),
|
||||
);
|
||||
|
||||
return newMsg;
|
||||
} catch (ex) {
|
||||
// The reaction already exists
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
Future<Message?> removeReaction(
|
||||
int messageId,
|
||||
String conversationJid,
|
||||
String emoji,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final msg = await ms.getMessageById(messageId, conversationJid);
|
||||
if (msg == null) {
|
||||
_log.warning('Failed to get message $messageId');
|
||||
return null;
|
||||
}
|
||||
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
reactionsTable,
|
||||
where: 'message_id = ? AND emoji = ? AND senderJid = ?',
|
||||
whereArgs: [
|
||||
messageId,
|
||||
emoji,
|
||||
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
|
||||
],
|
||||
);
|
||||
final count = await _countReactions(messageId, emoji);
|
||||
|
||||
if (count > 0) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
final newPreview = List<String>.from(msg.reactionsPreview)..remove(emoji);
|
||||
final newMsg = msg.copyWith(
|
||||
reactionsPreview: newPreview,
|
||||
);
|
||||
await ms.replaceMessageInCache(newMsg);
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: newMsg,
|
||||
),
|
||||
);
|
||||
return newMsg;
|
||||
}
|
||||
|
||||
Future<void> processNewReactions(
|
||||
Message msg,
|
||||
String senderJid,
|
||||
List<String> emojis,
|
||||
) async {
|
||||
// Get all reactions know for this message
|
||||
final allReactions = await getReactionsForMessage(msg.id);
|
||||
final userEmojis =
|
||||
allReactions.where((r) => r.senderJid == senderJid).map((r) => r.emoji);
|
||||
final removedReactions = userEmojis.where((e) => !emojis.contains(e));
|
||||
final addedReactions = emojis.where((e) => !userEmojis.contains(e));
|
||||
|
||||
// Remove and add the new reactions
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
for (final emoji in removedReactions) {
|
||||
final rows = await db.delete(
|
||||
reactionsTable,
|
||||
where: 'message_id = ? AND senderJid = ? AND emoji = ?',
|
||||
whereArgs: [msg.id, senderJid, emoji],
|
||||
);
|
||||
assert(rows == 1, 'Only one row should be removed');
|
||||
}
|
||||
|
||||
for (final emoji in addedReactions) {
|
||||
await db.insert(
|
||||
reactionsTable,
|
||||
Reaction(
|
||||
msg.id,
|
||||
senderJid,
|
||||
emoji,
|
||||
).toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
final newMessage = msg.copyWith(
|
||||
reactionsPreview: await getPreviewReactionsForMessage(msg.id),
|
||||
);
|
||||
await GetIt.I.get<MessageService>().replaceMessageInCache(
|
||||
newMessage,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: newMessage));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
@@ -42,7 +44,9 @@ class RosterService {
|
||||
String? contactDisplayName, {
|
||||
List<String> groups = const [],
|
||||
}) async {
|
||||
final item = await GetIt.I.get<DatabaseService>().addRosterItemFromData(
|
||||
// TODO(PapaTutuWawa): Handle groups
|
||||
final i = RosterItem(
|
||||
-1,
|
||||
avatarUrl,
|
||||
avatarHash,
|
||||
jid,
|
||||
@@ -50,10 +54,17 @@ class RosterService {
|
||||
subscription,
|
||||
ask,
|
||||
pseudoRosterItem,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
groups: groups,
|
||||
<String>[],
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
final item = i.copyWith(
|
||||
id: await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.insert(rosterTable, i.toDatabaseJson()),
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
@@ -76,19 +87,49 @@ class RosterService {
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}) async {
|
||||
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
||||
id,
|
||||
avatarUrl: avatarUrl,
|
||||
avatarHash: avatarHash,
|
||||
title: title,
|
||||
subscription: subscription,
|
||||
ask: ask,
|
||||
pseudoRosterItem: pseudoRosterItem,
|
||||
groups: groups,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
final i = <String, dynamic>{};
|
||||
|
||||
if (avatarUrl != null) {
|
||||
i['avatarUrl'] = avatarUrl;
|
||||
}
|
||||
if (avatarHash != null) {
|
||||
i['avatarHash'] = avatarHash;
|
||||
}
|
||||
if (title != null) {
|
||||
i['title'] = title;
|
||||
}
|
||||
/*
|
||||
if (groups != null) {
|
||||
i.groups = groups;
|
||||
}
|
||||
*/
|
||||
if (subscription != null) {
|
||||
i['subscription'] = subscription;
|
||||
}
|
||||
if (ask != null) {
|
||||
i['ask'] = ask;
|
||||
}
|
||||
if (contactId != notSpecified) {
|
||||
i['contactId'] = contactId as String?;
|
||||
}
|
||||
if (contactAvatarPath != notSpecified) {
|
||||
i['contactAvatarPath'] = contactAvatarPath as String?;
|
||||
}
|
||||
if (contactDisplayName != notSpecified) {
|
||||
i['contactDisplayName'] = contactDisplayName as String?;
|
||||
}
|
||||
if (pseudoRosterItem != notSpecified) {
|
||||
i['pseudoRosterItem'] = boolToInt(pseudoRosterItem as bool);
|
||||
}
|
||||
|
||||
final result =
|
||||
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
|
||||
rosterTable,
|
||||
i,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
final newItem = RosterItem.fromDatabaseJson(result);
|
||||
|
||||
// Update cache
|
||||
_rosterCache![newItem.jid] = newItem;
|
||||
@@ -96,10 +137,14 @@ class RosterService {
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
||||
/// Removes a roster item from the database and cache
|
||||
Future<void> removeRosterItem(int id) async {
|
||||
// NOTE: This call ensures that _rosterCache != null
|
||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
rosterTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||
|
||||
/// Update cache
|
||||
@@ -136,14 +181,16 @@ class RosterService {
|
||||
/// Load the roster from the database. This function is guarded against loading the
|
||||
/// roster multiple times and thus creating too many "RosterDiff" actions.
|
||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
||||
final itemsRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(rosterTable);
|
||||
final items = itemsRaw.map(RosterItem.fromDatabaseJson);
|
||||
|
||||
_rosterCache = <String, RosterItem>{};
|
||||
for (final item in items) {
|
||||
_rosterCache![item.jid] = item;
|
||||
}
|
||||
|
||||
return items;
|
||||
return items.toList();
|
||||
}
|
||||
|
||||
/// Attempts to add an item to the roster by first performing the roster set
|
||||
@@ -169,6 +216,7 @@ class RosterService {
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
final result = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getRosterManager()!
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/events.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||
import 'package:moxxyv2/service/language.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
@@ -29,6 +30,7 @@ import 'package:moxxyv2/service/moxxmpp/stream.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
@@ -176,6 +178,8 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
||||
SubscriptionRequestService(),
|
||||
);
|
||||
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||
final xmpp = XmppService();
|
||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -26,31 +30,69 @@ class StickersService {
|
||||
Future<StickerPack?> getStickerPackById(String id) async {
|
||||
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
|
||||
|
||||
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id);
|
||||
if (pack == null) return null;
|
||||
|
||||
_stickerPacks[id] = pack;
|
||||
return _stickerPacks[id];
|
||||
}
|
||||
|
||||
Future<Sticker?> getStickerByHashKey(String packId, String hashKey) async {
|
||||
final pack = await getStickerPackById(packId);
|
||||
if (pack == null) return null;
|
||||
|
||||
return firstWhereOrNull<Sticker>(
|
||||
pack.stickers,
|
||||
(sticker) => sticker.hashKey == hashKey,
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rawPack = await db.query(
|
||||
stickerPacksTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (rawPack.isEmpty) return null;
|
||||
|
||||
final rawStickers = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
sticker.*,
|
||||
fm.id AS fm_id,
|
||||
fm.path AS fm_path,
|
||||
fm.sourceUrls AS fm_sourceUrls,
|
||||
fm.mimeType AS fm_mimeType,
|
||||
fm.thumbnailType AS fm_thumbnailType,
|
||||
fm.thumbnailData AS fm_thumbnailData,
|
||||
fm.width AS fm_width,
|
||||
fm.height AS fm_height,
|
||||
fm.plaintextHashes AS fm_plaintextHashes,
|
||||
fm.encryptionKey AS fm_encryptionKey,
|
||||
fm.encryptionIv AS fm_encryptionIv,
|
||||
fm.encryptionScheme AS fm_encryptionScheme,
|
||||
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||
fm.filename AS fm_filename,
|
||||
fm.size AS fm_size
|
||||
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
|
||||
''',
|
||||
[id],
|
||||
);
|
||||
|
||||
_stickerPacks[id] = StickerPack.fromDatabaseJson(
|
||||
rawPack.first,
|
||||
rawStickers.map((sticker) {
|
||||
return Sticker.fromDatabaseJson(
|
||||
sticker,
|
||||
FileMetadata.fromDatabaseJson(
|
||||
getPrefixedSubMap(sticker, 'fm_'),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
return _stickerPacks[id]!;
|
||||
}
|
||||
|
||||
Future<List<StickerPack>> getStickerPacks() async {
|
||||
if (_stickerPacks.isEmpty) {
|
||||
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks();
|
||||
for (final pack in packs) {
|
||||
_stickerPacks[pack.id] = pack;
|
||||
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
|
||||
stickerPacksTable,
|
||||
columns: ['id'],
|
||||
);
|
||||
for (final rawPack in rawPackIds) {
|
||||
final id = rawPack['id']! as String;
|
||||
await getStickerPackById(id);
|
||||
}
|
||||
}
|
||||
|
||||
_log.finest('Got ${_stickerPacks.length} sticker packs');
|
||||
return _stickerPacks.values.toList();
|
||||
}
|
||||
|
||||
@@ -59,21 +101,27 @@ class StickersService {
|
||||
assert(pack != null, 'The sticker pack must exist');
|
||||
|
||||
// Delete the files
|
||||
final stickerPackPath = await getStickerPackPath(
|
||||
pack!.hashAlgorithm,
|
||||
pack.hashValue,
|
||||
);
|
||||
final stickerPackDir = Directory(stickerPackPath);
|
||||
if (stickerPackDir.existsSync()) {
|
||||
unawaited(
|
||||
stickerPackDir.delete(
|
||||
recursive: true,
|
||||
),
|
||||
for (final sticker in pack!.stickers) {
|
||||
if (sticker.fileMetadata.path == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||
sticker.fileMetadata.id,
|
||||
path: null,
|
||||
);
|
||||
final file = File(sticker.fileMetadata.path!);
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from the database
|
||||
await GetIt.I.get<DatabaseService>().removeStickerPackById(id);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
stickerPacksTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
// Remove from the cache
|
||||
_stickerPacks.remove(id);
|
||||
@@ -107,16 +155,6 @@ class StickersService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the sticker pack with hash algorithm [algo] and hash [hash].
|
||||
/// Ensures that the directory exists before returning.
|
||||
Future<String> _getStickerPackPath(String algo, String hash) async {
|
||||
final stickerDirPath = await getStickerPackPath(algo, hash);
|
||||
final stickerDir = Directory(stickerDirPath);
|
||||
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
||||
|
||||
return stickerDirPath;
|
||||
}
|
||||
|
||||
Future<void> importFromPubSubWithEvent(
|
||||
moxxmpp.JID jid,
|
||||
String stickerPackId,
|
||||
@@ -158,24 +196,78 @@ class StickersService {
|
||||
return installFromPubSub(stickerPackRaw);
|
||||
}
|
||||
|
||||
Future<void> _addStickerPackFromData(StickerPack pack) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
stickerPacksTable,
|
||||
pack.toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Sticker> _addStickerFromData(
|
||||
String id,
|
||||
String stickerPackId,
|
||||
String desc,
|
||||
Map<String, String> suggests,
|
||||
FileMetadata fileMetadata,
|
||||
) async {
|
||||
final s = Sticker(
|
||||
id,
|
||||
stickerPackId,
|
||||
desc,
|
||||
suggests,
|
||||
fileMetadata,
|
||||
);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
stickersTable,
|
||||
s.toDatabaseJson(),
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
|
||||
assert(!remotePack.local, 'Sticker pack must be remote');
|
||||
|
||||
final stickerPackPath = await _getStickerPackPath(
|
||||
remotePack.hashAlgorithm,
|
||||
remotePack.hashValue,
|
||||
);
|
||||
|
||||
var success = true;
|
||||
final stickers = List<Sticker>.from(remotePack.stickers);
|
||||
for (var i = 0; i < stickers.length; i++) {
|
||||
final sticker = stickers[i];
|
||||
final stickerPath = p.join(
|
||||
stickerPackPath,
|
||||
sticker.hashes.values.first,
|
||||
final stickerPath = await computeCachedPathForFile(
|
||||
sticker.fileMetadata.filename,
|
||||
sticker.fileMetadata.plaintextHashes,
|
||||
);
|
||||
|
||||
// Get file metadata
|
||||
final fileMetadataRaw =
|
||||
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
sticker.fileMetadata.sourceUrls!,
|
||||
p.basename(stickerPath),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.fileMetadata.plaintextHashes,
|
||||
null,
|
||||
sticker.fileMetadata.size,
|
||||
),
|
||||
sticker.fileMetadata.mimeType,
|
||||
sticker.fileMetadata.size,
|
||||
sticker.fileMetadata.width != null &&
|
||||
sticker.fileMetadata.height != null
|
||||
? Size(
|
||||
sticker.fileMetadata.width!.toDouble(),
|
||||
sticker.fileMetadata.height!.toDouble(),
|
||||
)
|
||||
: null,
|
||||
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||
null,
|
||||
null,
|
||||
path: stickerPath,
|
||||
);
|
||||
|
||||
if (!fileMetadataRaw.retrieved) {
|
||||
final downloadStatusCode = await downloadFile(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
|
||||
stickerPath,
|
||||
(_, __) {},
|
||||
);
|
||||
@@ -185,9 +277,15 @@ class StickersService {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
stickers[i] = sticker.copyWith(
|
||||
path: stickerPath,
|
||||
hashKey: getStickerHashKey(sticker.hashes),
|
||||
}
|
||||
|
||||
stickers[i] = await _addStickerFromData(
|
||||
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
remotePack.hashValue,
|
||||
sticker.desc,
|
||||
sticker.suggests,
|
||||
fileMetadataRaw.fileMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,27 +295,7 @@ class StickersService {
|
||||
}
|
||||
|
||||
// Add the sticker pack to the database
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
await db.addStickerPackFromData(remotePack);
|
||||
|
||||
// Add the stickers to the database
|
||||
final stickersDb = List<Sticker>.empty(growable: true);
|
||||
for (final sticker in stickers) {
|
||||
stickersDb.add(
|
||||
await db.addStickerFromData(
|
||||
sticker.mediaType,
|
||||
sticker.desc,
|
||||
sticker.size,
|
||||
sticker.width,
|
||||
sticker.height,
|
||||
sticker.hashes,
|
||||
sticker.urlSources,
|
||||
sticker.path,
|
||||
remotePack.hashValue,
|
||||
sticker.suggests,
|
||||
),
|
||||
);
|
||||
}
|
||||
await _addStickerPackFromData(remotePack);
|
||||
|
||||
// Publish but don't block
|
||||
unawaited(
|
||||
@@ -225,7 +303,7 @@ class StickersService {
|
||||
);
|
||||
|
||||
return remotePack.copyWith(
|
||||
stickers: stickersDb,
|
||||
stickers: stickers,
|
||||
local: true,
|
||||
);
|
||||
}
|
||||
@@ -299,8 +377,6 @@ class StickersService {
|
||||
final stickerDir = Directory(stickerDirPath);
|
||||
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
|
||||
// Create the sticker pack first
|
||||
final stickerPack = StickerPack(
|
||||
pack.hashValue,
|
||||
@@ -312,33 +388,65 @@ class StickersService {
|
||||
pack.restricted,
|
||||
true,
|
||||
);
|
||||
await db.addStickerPackFromData(stickerPack);
|
||||
await _addStickerPackFromData(stickerPack);
|
||||
|
||||
// Add all stickers
|
||||
final stickers = List<Sticker>.empty(growable: true);
|
||||
for (final sticker in pack.stickers) {
|
||||
final filename = sticker.metadata.name!;
|
||||
final stickerFile = archive.findFile(filename)!;
|
||||
final stickerPath = p.join(stickerDirPath, filename);
|
||||
await File(stickerPath).writeAsBytes(
|
||||
stickerFile.content as List<int>,
|
||||
// Get the "path" to the sticker
|
||||
final stickerPath = await computeCachedPathForFile(
|
||||
sticker.metadata.name!,
|
||||
sticker.metadata.hashes,
|
||||
);
|
||||
|
||||
stickers.add(
|
||||
await db.addStickerFromData(
|
||||
sticker.metadata.mediaType!,
|
||||
sticker.metadata.desc!,
|
||||
sticker.metadata.size!,
|
||||
// Get metadata
|
||||
final urlSources = sticker.sources
|
||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList();
|
||||
final fileMetadataRaw = await GetIt.I
|
||||
.get<FilesService>()
|
||||
.createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
urlSources,
|
||||
p.basename(stickerPath),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.metadata.hashes,
|
||||
sticker.sources
|
||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
|
||||
.toList(),
|
||||
stickerPath,
|
||||
null,
|
||||
sticker.metadata.size,
|
||||
),
|
||||
sticker.metadata.mediaType,
|
||||
sticker.metadata.size,
|
||||
sticker.metadata.width != null && sticker.metadata.height != null
|
||||
? Size(
|
||||
sticker.metadata.width!.toDouble(),
|
||||
sticker.metadata.height!.toDouble(),
|
||||
)
|
||||
: null,
|
||||
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||
null,
|
||||
null,
|
||||
path: stickerPath,
|
||||
);
|
||||
|
||||
// Only copy the sticker to storage if we don't already have it
|
||||
if (!fileMetadataRaw.retrieved) {
|
||||
final stickerFile = archive.findFile(sticker.metadata.name!)!;
|
||||
await File(stickerPath).writeAsBytes(
|
||||
stickerFile.content as List<int>,
|
||||
);
|
||||
}
|
||||
|
||||
stickers.add(
|
||||
await _addStickerFromData(
|
||||
getStrongestHashFromMap(sticker.metadata.hashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
pack.hashValue,
|
||||
sticker.metadata.desc!,
|
||||
sticker.suggests,
|
||||
fileMetadataRaw.fileMetadata,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class SubscriptionRequestService {
|
||||
@@ -8,15 +10,18 @@ class SubscriptionRequestService {
|
||||
|
||||
final Lock _lock = Lock();
|
||||
|
||||
DatabaseService get _db => GetIt.I.get<DatabaseService>();
|
||||
|
||||
/// Only load data from the database into
|
||||
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
||||
/// been loaded.
|
||||
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
||||
await _lock.synchronized(() async {
|
||||
_subscriptionRequests ??= List<String>.from(
|
||||
await _db.getSubscriptionRequests(),
|
||||
(await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(subscriptionsTable))
|
||||
.map((m) => m['jid']! as String)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -33,7 +38,13 @@ class SubscriptionRequestService {
|
||||
if (!_subscriptionRequests!.contains(jid)) {
|
||||
_subscriptionRequests!.add(jid);
|
||||
|
||||
await _db.addSubscriptionRequest(jid);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
subscriptionsTable,
|
||||
{
|
||||
'jid': jid,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -44,7 +55,11 @@ class SubscriptionRequestService {
|
||||
await _lock.synchronized(() async {
|
||||
if (_subscriptionRequests!.contains(jid)) {
|
||||
_subscriptionRequests!.remove(jid);
|
||||
await _db.removeSubscriptionRequest(jid);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
subscriptionsTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,19 +15,20 @@ import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
@@ -35,9 +36,8 @@ import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
@@ -47,6 +47,7 @@ class XmppService {
|
||||
XmppService() {
|
||||
_eventHandler.addMatchers([
|
||||
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
|
||||
EventTypeMatcher<StreamNegotiationsDoneEvent>(_onStreamNegotiationsDone),
|
||||
EventTypeMatcher<ResourceBoundEvent>(_onResourceBound),
|
||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(
|
||||
_onSubscriptionRequestReceived,
|
||||
@@ -222,7 +223,6 @@ class XmppService {
|
||||
timestamp,
|
||||
conn.connectionSettings.jid.toString(),
|
||||
recipient,
|
||||
sticker != null,
|
||||
sid,
|
||||
false,
|
||||
c.type == ConversationType.note ? true : c.encrypted,
|
||||
@@ -231,9 +231,7 @@ class XmppService {
|
||||
originId: originId,
|
||||
quoteId: quotedMessage?.sid,
|
||||
stickerPackId: sticker?.stickerPackId,
|
||||
stickerHashKey: sticker?.hashKey,
|
||||
srcUrl: sticker?.urlSources.first,
|
||||
mediaType: sticker?.mediaType,
|
||||
fileMetadata: sticker?.fileMetadata,
|
||||
received: c.type == ConversationType.note ? true : false,
|
||||
displayed: c.type == ConversationType.note ? true : false,
|
||||
);
|
||||
@@ -260,6 +258,7 @@ class XmppService {
|
||||
}
|
||||
|
||||
if (conversation?.type == ConversationType.chat) {
|
||||
final moxxmppSticker = sticker?.toMoxxmpp();
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
@@ -273,23 +272,12 @@ class XmppService {
|
||||
chatState: chatState,
|
||||
shouldEncrypt: conversation!.encrypted,
|
||||
stickerPackId: sticker?.stickerPackId,
|
||||
sfs: sticker == null
|
||||
? null
|
||||
: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: sticker.mediaType,
|
||||
width: sticker.width,
|
||||
height: sticker.height,
|
||||
desc: sticker.desc,
|
||||
size: sticker.size,
|
||||
thumbnails: [],
|
||||
hashes: sticker.hashes,
|
||||
),
|
||||
sticker.urlSources
|
||||
// ignore: unnecessary_lambdas
|
||||
.map((s) => StatelessFileSharingUrlSource(s))
|
||||
.toList(),
|
||||
),
|
||||
sfs: moxxmppSticker != null
|
||||
? StatelessFileSharingData(
|
||||
moxxmppSticker.metadata,
|
||||
moxxmppSticker.sources,
|
||||
)
|
||||
: null,
|
||||
setOOBFallbackBody: sticker != null ? false : true,
|
||||
),
|
||||
);
|
||||
@@ -301,50 +289,67 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
MediaFileLocation? _getMessageSrcUrl(MessageEvent event) {
|
||||
if (event.sfs != null) {
|
||||
final source = firstWhereOrNull(
|
||||
event.sfs!.sources,
|
||||
(StatelessFileSharingSource source) {
|
||||
return source is StatelessFileSharingUrlSource ||
|
||||
source is StatelessFileSharingEncryptedSource;
|
||||
},
|
||||
);
|
||||
MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
|
||||
if (event.sfs?.sources.isNotEmpty ?? false) {
|
||||
// final source = firstWhereOrNull(
|
||||
// event.sfs!.sources,
|
||||
// (StatelessFileSharingSource source) {
|
||||
// return source is StatelessFileSharingUrlSource ||
|
||||
// source is StatelessFileSharingEncryptedSource;
|
||||
// },
|
||||
// );
|
||||
|
||||
final name = event.sfs?.metadata.name;
|
||||
if (source is StatelessFileSharingUrlSource) {
|
||||
final hasUrlSource = firstWhereOrNull(
|
||||
event.sfs!.sources,
|
||||
(src) => src is StatelessFileSharingUrlSource,
|
||||
) !=
|
||||
null;
|
||||
|
||||
final name = event.sfs!.metadata.name;
|
||||
if (hasUrlSource) {
|
||||
final sources = event.sfs!.sources
|
||||
.whereType<StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList();
|
||||
return MediaFileLocation(
|
||||
source.url,
|
||||
name != null ? escapeFilename(name) : filenameFromUrl(source.url),
|
||||
sources,
|
||||
name != null ? escapeFilename(name) : filenameFromUrl(sources.first),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
event.sfs?.metadata.hashes,
|
||||
event.sfs!.metadata.hashes,
|
||||
null,
|
||||
event.sfs!.metadata.size,
|
||||
);
|
||||
} else {
|
||||
final esource = source! as StatelessFileSharingEncryptedSource;
|
||||
final encryptedSource = firstWhereOrNull(
|
||||
event.sfs!.sources,
|
||||
(src) => src is StatelessFileSharingEncryptedSource,
|
||||
)! as StatelessFileSharingEncryptedSource;
|
||||
|
||||
return MediaFileLocation(
|
||||
esource.source.url,
|
||||
[encryptedSource.source.url],
|
||||
name != null
|
||||
? escapeFilename(name)
|
||||
: filenameFromUrl(esource.source.url),
|
||||
esource.encryption.toNamespace(),
|
||||
esource.key,
|
||||
esource.iv,
|
||||
: filenameFromUrl(encryptedSource.source.url),
|
||||
encryptedSource.encryption.toNamespace(),
|
||||
encryptedSource.key,
|
||||
encryptedSource.iv,
|
||||
event.sfs?.metadata.hashes,
|
||||
esource.hashes,
|
||||
encryptedSource.hashes,
|
||||
event.sfs!.metadata.size,
|
||||
);
|
||||
}
|
||||
} else if (event.oob != null) {
|
||||
return MediaFileLocation(
|
||||
event.oob!.url!,
|
||||
[event.oob!.url!],
|
||||
filenameFromUrl(event.oob!.url!),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,38 +360,44 @@ class XmppService {
|
||||
final result = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getDiscoManager()!
|
||||
.discoInfoQuery(event.fromJid.toString());
|
||||
.discoInfoQuery(event.fromJid);
|
||||
if (result.isType<DiscoError>()) return;
|
||||
|
||||
final info = result.get<DiscoInfo>();
|
||||
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
|
||||
unawaited(
|
||||
GetIt.I.get<XmppConnection>().sendStanza(
|
||||
StanzaDetails(
|
||||
Stanza.message(
|
||||
to: event.fromJid.toBare().toString(),
|
||||
type: event.type,
|
||||
children: [
|
||||
makeChatMarker(
|
||||
'received',
|
||||
event.stanzaId.originId ?? event.sid,
|
||||
event.originId ?? event.sid,
|
||||
)
|
||||
],
|
||||
),
|
||||
awaitable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (event.deliveryReceiptRequested &&
|
||||
info.features.contains(deliveryXmlns)) {
|
||||
unawaited(
|
||||
GetIt.I.get<XmppConnection>().sendStanza(
|
||||
StanzaDetails(
|
||||
Stanza.message(
|
||||
to: event.fromJid.toBare().toString(),
|
||||
type: event.type,
|
||||
children: [
|
||||
makeMessageDeliveryResponse(
|
||||
event.stanzaId.originId ?? event.sid,
|
||||
event.originId ?? event.sid,
|
||||
)
|
||||
],
|
||||
),
|
||||
awaitable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -401,6 +412,7 @@ class XmppService {
|
||||
|
||||
unawaited(
|
||||
GetIt.I.get<XmppConnection>().sendStanza(
|
||||
StanzaDetails(
|
||||
Stanza.message(
|
||||
to: to,
|
||||
type: 'chat',
|
||||
@@ -408,6 +420,8 @@ class XmppService {
|
||||
makeChatMarker('displayed', sid),
|
||||
],
|
||||
),
|
||||
awaitable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -442,7 +456,10 @@ class XmppService {
|
||||
_loginTriggeredFromUI = triggeredFromUI;
|
||||
conn
|
||||
..connectionSettings = settings
|
||||
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource;
|
||||
..getNegotiatorById<StreamManagementNegotiator>(
|
||||
streamManagementNegotiator,
|
||||
)!
|
||||
.resource = lastResource;
|
||||
unawaited(
|
||||
conn.connect(
|
||||
waitForConnection: true,
|
||||
@@ -463,7 +480,10 @@ class XmppService {
|
||||
_loginTriggeredFromUI = triggeredFromUI;
|
||||
conn
|
||||
..connectionSettings = settings
|
||||
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource;
|
||||
..getNegotiatorById<StreamManagementNegotiator>(
|
||||
streamManagementNegotiator,
|
||||
)!
|
||||
.resource = lastResource;
|
||||
installEventHandlers();
|
||||
return conn.connect(
|
||||
waitForConnection: true,
|
||||
@@ -471,34 +491,6 @@ class XmppService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrapper function for creating shared media entries for the given paths.
|
||||
/// [messages] is the mapping of "File path -> Recipient -> Message" required for
|
||||
/// setting the single shared medium's message Id attribute.
|
||||
/// [paths] is the list of paths to create shared media entries for.
|
||||
/// [recipient] is the bare string JID that the messages will be sent to.
|
||||
/// [conversationJid] is the JID of the conversation these shared media entries
|
||||
/// belong to.
|
||||
Future<List<SharedMedium>> _createSharedMedia(
|
||||
Map<String, Map<String, Message>> messages,
|
||||
List<String> paths,
|
||||
String recipient,
|
||||
String conversationJid,
|
||||
) async {
|
||||
final sharedMedia = List<SharedMedium>.empty(growable: true);
|
||||
for (final path in paths) {
|
||||
sharedMedia.add(
|
||||
await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
path,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
conversationJid,
|
||||
messages[path]![recipient]!.id,
|
||||
mime: lookupMimeType(path),
|
||||
),
|
||||
);
|
||||
}
|
||||
return sharedMedia;
|
||||
}
|
||||
|
||||
Future<void> sendFiles(List<String> paths, List<String> recipients) async {
|
||||
// Create a new message
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
@@ -516,17 +508,13 @@ class XmppService {
|
||||
final encrypt = <String, bool>{};
|
||||
// Recipient -> Last message Id
|
||||
final lastMessages = <String, Message>{};
|
||||
// Path -> Metadata Id
|
||||
final metadataMap = <String, String>{};
|
||||
|
||||
// Create the messages and shared media entries
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
for (final path in paths) {
|
||||
final pathMime = lookupMimeType(path);
|
||||
|
||||
for (final recipient in recipients) {
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
encrypt[recipient] =
|
||||
conversation?.encrypted ?? prefs.enableOmemoByDefault;
|
||||
|
||||
// TODO(Unknown): Do the same for videos
|
||||
if (pathMime != null && pathMime.startsWith('image/')) {
|
||||
final imageSize = await getImageSizeFromPath(path);
|
||||
@@ -540,25 +528,47 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
final metadata =
|
||||
await GetIt.I.get<FilesService>().addFileMetadataFromData(
|
||||
FileMetadata(
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
path,
|
||||
null,
|
||||
pathMime,
|
||||
File(path).lengthSync(),
|
||||
null,
|
||||
null,
|
||||
dimensions[path]?.width.toInt(),
|
||||
dimensions[path]?.height.toInt(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
pathlib.basename(path),
|
||||
),
|
||||
);
|
||||
metadataMap[path] = metadata.id;
|
||||
|
||||
for (final recipient in recipients) {
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
encrypt[recipient] =
|
||||
conversation?.encrypted ?? prefs.enableOmemoByDefault;
|
||||
|
||||
final msg = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
conn.connectionSettings.jid.toString(),
|
||||
recipient,
|
||||
true,
|
||||
conn.generateId(),
|
||||
false,
|
||||
conversation?.type == ConversationType.note
|
||||
? true
|
||||
: encrypt[recipient]!,
|
||||
// TODO(Unknown): Maybe make this depend on some setting
|
||||
false,
|
||||
mediaUrl: path,
|
||||
mediaType: pathMime,
|
||||
false,
|
||||
fileMetadata: metadata,
|
||||
originId: conn.generateId(),
|
||||
mediaWidth: dimensions[path]?.width.toInt(),
|
||||
mediaHeight: dimensions[path]?.height.toInt(),
|
||||
filename: pathlib.basename(path),
|
||||
isUploading:
|
||||
conversation?.type != ConversationType.note ? true : false,
|
||||
received: conversation?.type == ConversationType.note ? true : false,
|
||||
@@ -578,11 +588,7 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
// Create the shared media entries
|
||||
// Recipient -> [Shared Medium]
|
||||
final sharedMediaMap = <String, List<SharedMedium>>{};
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
|
||||
for (final recipient in recipients) {
|
||||
await cs.createOrUpdateConversation(
|
||||
recipient,
|
||||
@@ -590,7 +596,7 @@ class XmppService {
|
||||
// Create
|
||||
final rosterItem = await rs.getRosterItemByJid(recipient);
|
||||
final contactId = await css.getContactIdForJid(recipient);
|
||||
var newConversation = await cs.addConversationFromData(
|
||||
final newConversation = await cs.addConversationFromData(
|
||||
// TODO(Unknown): Should we use the JID parser?
|
||||
rosterItem?.title ?? recipient.split('@').first,
|
||||
lastMessages[recipient],
|
||||
@@ -602,22 +608,11 @@ class XmppService {
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
paths.length,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(recipient),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
final sharedMedia = await _createSharedMedia(
|
||||
messages,
|
||||
paths,
|
||||
recipient,
|
||||
newConversation.jid,
|
||||
);
|
||||
newConversation = newConversation.copyWith(
|
||||
sharedMedia: sharedMedia.sublist(0, 8),
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
cs.setConversation(newConversation);
|
||||
|
||||
@@ -628,28 +623,11 @@ class XmppService {
|
||||
},
|
||||
update: (c) async {
|
||||
// Update
|
||||
var newConversation = await cs.updateConversation(
|
||||
final newConversation = await cs.updateConversation(
|
||||
c.jid,
|
||||
lastMessage: lastMessages[recipient],
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
open: true,
|
||||
sharedMediaAmount: c.sharedMediaAmount + paths.length,
|
||||
);
|
||||
|
||||
sharedMediaMap[recipient] = await _createSharedMedia(
|
||||
messages,
|
||||
paths,
|
||||
recipient,
|
||||
// TODO(Unknown): Remove since recipient and c.jid are now the same
|
||||
c.jid,
|
||||
);
|
||||
|
||||
newConversation = newConversation.copyWith(
|
||||
sharedMedia: clampedListPrependAll(
|
||||
c.sharedMedia,
|
||||
sharedMediaMap[recipient]!,
|
||||
8,
|
||||
),
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
@@ -710,6 +688,7 @@ class XmppService {
|
||||
pathMime,
|
||||
encrypt,
|
||||
messages[path]!,
|
||||
metadataMap[path]!,
|
||||
thumbnails[path] ?? [],
|
||||
),
|
||||
);
|
||||
@@ -763,18 +742,10 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnectionStateChanged(
|
||||
ConnectionStateChangedEvent event, {
|
||||
Future<void> _onStreamNegotiationsDone(
|
||||
StreamNegotiationsDoneEvent event, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
setNotificationText(event.state);
|
||||
|
||||
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
|
||||
event.before,
|
||||
event.state,
|
||||
);
|
||||
|
||||
if (event.state == XmppConnectionState.connected) {
|
||||
final connection = GetIt.I.get<XmppConnection>();
|
||||
|
||||
// TODO(Unknown): Maybe have something better
|
||||
@@ -838,6 +809,17 @@ class XmppService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onConnectionStateChanged(
|
||||
ConnectionStateChangedEvent event, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
setNotificationText(event.state);
|
||||
|
||||
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
|
||||
event.before,
|
||||
event.state,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onResourceBound(
|
||||
@@ -876,11 +858,10 @@ class XmppService {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
_log.finest('Received delivery receipt from ${event.from}');
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final sender = event.from.toBare().toString();
|
||||
final dbMsg = await db.getMessageByXmppId(event.id, sender);
|
||||
final dbMsg = await ms.getMessageByXmppId(event.id, sender);
|
||||
if (dbMsg == null) {
|
||||
_log.warning(
|
||||
'Did not find the message with id ${event.id} in the database!',
|
||||
@@ -907,11 +888,10 @@ class XmppService {
|
||||
Future<void> _onChatMarker(ChatMarkerEvent event, {dynamic extra}) async {
|
||||
_log.finest('Chat marker from ${event.from}');
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final sender = event.from.toBare().toString();
|
||||
final dbMsg = await db.getMessageByXmppId(event.id, sender);
|
||||
final dbMsg = await ms.getMessageByXmppId(event.id, sender);
|
||||
if (dbMsg == null) {
|
||||
_log.warning('Did not find the message in the database!');
|
||||
return;
|
||||
@@ -1010,9 +990,8 @@ class XmppService {
|
||||
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
|
||||
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||
return embeddedFile != null &&
|
||||
Uri.parse(embeddedFile.url).scheme == 'https' &&
|
||||
implies(event.oob != null, event.body == event.oob?.url) &&
|
||||
event.stickerPackId == null;
|
||||
Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
|
||||
implies(event.oob != null, event.body == event.oob?.url);
|
||||
}
|
||||
|
||||
/// Handle a message retraction given the MessageEvent [event].
|
||||
@@ -1141,9 +1120,10 @@ class XmppService {
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
|
||||
final msg = await ms.getMessageByStanzaOrOriginId(
|
||||
conversationJid,
|
||||
final msg = await ms.getMessageByXmppId(
|
||||
event.messageReactions!.messageId,
|
||||
conversationJid,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
if (msg == null) {
|
||||
_log.warning(
|
||||
@@ -1152,80 +1132,10 @@ class XmppService {
|
||||
return;
|
||||
}
|
||||
|
||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
final sender = event.fromJid.toBare().toString();
|
||||
final isCarbon = sender == state.jid;
|
||||
final reactions = List<Reaction>.from(msg.reactions);
|
||||
final emojis = event.messageReactions!.emojis;
|
||||
|
||||
// Find out what emojis the sender has already sent
|
||||
final sentEmojis = msg.reactions
|
||||
.where((r) {
|
||||
return isCarbon ? r.reactedBySelf : r.senders.contains(sender);
|
||||
})
|
||||
.map((r) => r.emoji)
|
||||
.toList();
|
||||
// Find out what reactions were removed
|
||||
final removedEmojis = sentEmojis.where((e) => !emojis.contains(e));
|
||||
|
||||
for (final emoji in emojis) {
|
||||
final i = reactions.indexWhere((r) => r.emoji == emoji);
|
||||
if (i == -1) {
|
||||
reactions.add(
|
||||
Reaction(
|
||||
isCarbon ? [] : [sender],
|
||||
emoji,
|
||||
isCarbon,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
List<String> senders;
|
||||
if (isCarbon) {
|
||||
senders = reactions[i].senders;
|
||||
} else {
|
||||
// Ensure that we don't add a sender multiple times to the same reaction
|
||||
if (reactions[i].senders.contains(sender)) {
|
||||
senders = reactions[i].senders;
|
||||
} else {
|
||||
senders = [
|
||||
...reactions[i].senders,
|
||||
sender,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
reactions[i] = reactions[i].copyWith(
|
||||
senders: senders,
|
||||
reactedBySelf: isCarbon ? true : reactions[i].reactedBySelf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final emoji in removedEmojis) {
|
||||
final i = reactions.indexWhere((r) => r.emoji == emoji);
|
||||
assert(i >= -1, 'The reaction must exist');
|
||||
|
||||
if (isCarbon && reactions[i].senders.isEmpty ||
|
||||
!isCarbon &&
|
||||
reactions[i].senders.length == 1 &&
|
||||
!reactions[i].reactedBySelf) {
|
||||
reactions.removeAt(i);
|
||||
} else {
|
||||
reactions[i] = reactions[i].copyWith(
|
||||
senders: isCarbon
|
||||
? reactions[i].senders
|
||||
: reactions[i].senders.where((s) => s != sender).toList(),
|
||||
reactedBySelf: isCarbon ? false : reactions[i].reactedBySelf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final newMessage = await ms.updateMessage(
|
||||
msg.id,
|
||||
reactions: reactions,
|
||||
);
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(message: newMessage),
|
||||
await GetIt.I.get<ReactionsService>().processNewReactions(
|
||||
msg,
|
||||
event.fromJid.toBare().toString(),
|
||||
event.messageReactions!.emojis,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1313,84 +1223,64 @@ class XmppService {
|
||||
}
|
||||
|
||||
// The Url of the file embedded in the message, if there is one.
|
||||
final embeddedFile = _getMessageSrcUrl(event);
|
||||
final embeddedFile = _getEmbeddedFile(event);
|
||||
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
|
||||
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
|
||||
// The dimensions of the file, if available.
|
||||
final dimensions = _getDimensions(event);
|
||||
// Indicates if we should auto-download the file, if a file is specified in the message
|
||||
final shouldDownload = await _shouldDownloadFile(conversationJid);
|
||||
// The thumbnail for the embedded file.
|
||||
final thumbnailData = _getThumbnailData(event);
|
||||
final shouldDownload =
|
||||
isFileEmbedded && await _shouldDownloadFile(conversationJid);
|
||||
// Indicates if a notification should be created for the message.
|
||||
// The way this variable works is that if we can download the file, then the
|
||||
// notification will be created later by the [DownloadService]. If we don't want the
|
||||
// download to happen automatically, then the notification should happen immediately.
|
||||
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
|
||||
var shouldNotify = !(isInRoster && shouldDownload);
|
||||
// A guess for the Mime type of the embedded file.
|
||||
var mimeGuess = _getMimeGuess(event);
|
||||
// Guess a sticker hash key, if the message is a sticker
|
||||
final stickerHashKey = event.stickerPackId != null
|
||||
? getStickerHashKey(event.sfs!.metadata.hashes)
|
||||
: null;
|
||||
// The potential sticker pack
|
||||
final stickerPack = event.stickerPackId != null
|
||||
? await GetIt.I.get<StickersService>().getStickerPackById(
|
||||
event.stickerPackId!,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Automatically download the sticker pack, if
|
||||
// - a sticker was received,
|
||||
// - the sender is in the roster,
|
||||
// - we don't have the sticker pack locally,
|
||||
// - and it is enabled in the settings
|
||||
if (event.stickerPackId != null &&
|
||||
stickerPack == null &&
|
||||
prefs.autoDownloadStickersFromContacts &&
|
||||
isInRoster) {
|
||||
unawaited(
|
||||
GetIt.I.get<StickersService>().importFromPubSubWithEvent(
|
||||
event.fromJid,
|
||||
event.stickerPackId!,
|
||||
),
|
||||
FileMetadataWrapper? fileMetadata;
|
||||
if (isFileEmbedded) {
|
||||
final thumbnail = _getThumbnailData(event);
|
||||
fileMetadata =
|
||||
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||
embeddedFile!,
|
||||
mimeGuess,
|
||||
embeddedFile.size,
|
||||
dimensions,
|
||||
// TODO(Unknown): Maybe we switch to something else?
|
||||
thumbnail != null ? 'blurhash' : null,
|
||||
thumbnail,
|
||||
createHashPointers: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the message in the database
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final dimensions = _getDimensions(event);
|
||||
var message = await ms.addMessageFromData(
|
||||
messageBody,
|
||||
messageTimestamp,
|
||||
event.fromJid.toString(),
|
||||
conversationJid,
|
||||
isFileEmbedded || event.fun != null || event.stickerPackId != null,
|
||||
event.sid,
|
||||
event.fun != null,
|
||||
event.encrypted,
|
||||
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
|
||||
false,
|
||||
srcUrl: embeddedFile?.url,
|
||||
filename: event.fun?.name ?? embeddedFile?.filename,
|
||||
key: embeddedFile?.keyBase64,
|
||||
iv: embeddedFile?.ivBase64,
|
||||
encryptionScheme: embeddedFile?.encryptionScheme,
|
||||
mediaType: mimeGuess,
|
||||
thumbnailData: thumbnailData,
|
||||
mediaWidth: dimensions?.width.toInt(),
|
||||
mediaHeight: dimensions?.height.toInt(),
|
||||
fileMetadata: fileMetadata?.fileMetadata,
|
||||
quoteId: replyId,
|
||||
originId: event.stanzaId.originId,
|
||||
originId: event.originId,
|
||||
errorType: errorTypeFromException(event.other['encryption_error']),
|
||||
plaintextHashes: event.sfs?.metadata.hashes,
|
||||
stickerPackId: event.stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
);
|
||||
|
||||
// Attempt to auto-download the embedded file
|
||||
if (isFileEmbedded && shouldDownload) {
|
||||
// Attempt to auto-download the embedded file, if
|
||||
// - there is a file attached and
|
||||
// - we have not retrieved the file metadata
|
||||
if (shouldDownload && !(fileMetadata?.retrieved ?? false)) {
|
||||
final fts = GetIt.I.get<HttpFileTransferService>();
|
||||
final metadata = await peekFile(embeddedFile!.url);
|
||||
final metadata = await peekFile(embeddedFile!.urls.first);
|
||||
|
||||
_log.finest('Advertised file MIME: ${metadata.mime}');
|
||||
if (metadata.mime != null) mimeGuess = metadata.mime;
|
||||
@@ -1408,6 +1298,7 @@ class XmppService {
|
||||
FileDownloadJob(
|
||||
embeddedFile,
|
||||
message.id,
|
||||
message.fileMetadata!.id,
|
||||
conversationJid,
|
||||
mimeGuess,
|
||||
),
|
||||
@@ -1416,6 +1307,10 @@ class XmppService {
|
||||
// Make sure we create the notification
|
||||
shouldNotify = true;
|
||||
}
|
||||
} else {
|
||||
if (fileMetadata?.retrieved ?? false) {
|
||||
_log.info('Not downloading file as we already have it locally');
|
||||
}
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
@@ -1448,9 +1343,6 @@ class XmppService {
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
message.encrypted,
|
||||
// Always use 0 here, since a possible shared media item only is created
|
||||
// afterwards.
|
||||
0,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(conversationJid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
@@ -1544,23 +1436,35 @@ class XmppService {
|
||||
}
|
||||
|
||||
// The Url of the file embedded in the message, if there is one.
|
||||
final embeddedFile = _getMessageSrcUrl(event);
|
||||
final embeddedFile = _getEmbeddedFile(event);
|
||||
// Is there even a file we can download?
|
||||
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
|
||||
|
||||
if (isFileEmbedded) {
|
||||
final shouldDownload = await _shouldDownloadFile(conversationJid);
|
||||
final fileMetadata =
|
||||
await GetIt.I.get<FilesService>().getFileMetadataFromHash(
|
||||
embeddedFile!.plaintextHashes,
|
||||
);
|
||||
final shouldDownload =
|
||||
await _shouldDownloadFile(conversationJid) && fileMetadata == null;
|
||||
|
||||
final oldFileMetadata = message.fileMetadata;
|
||||
message = await ms.updateMessage(
|
||||
message.id,
|
||||
srcUrl: embeddedFile!.url,
|
||||
key: embeddedFile.keyBase64,
|
||||
iv: embeddedFile.ivBase64,
|
||||
fileMetadata: fileMetadata ?? notSpecified,
|
||||
isFileUploadNotification: false,
|
||||
isDownloading: shouldDownload,
|
||||
sid: event.sid,
|
||||
originId: event.stanzaId.originId,
|
||||
originId: event.originId,
|
||||
);
|
||||
|
||||
// Remove the old entry
|
||||
if (fileMetadata != null) {
|
||||
await GetIt.I
|
||||
.get<FilesService>()
|
||||
.removeFileMetadata(oldFileMetadata!.id);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
sendEvent(MessageUpdatedEvent(message: message));
|
||||
|
||||
@@ -1570,11 +1474,16 @@ class XmppService {
|
||||
FileDownloadJob(
|
||||
embeddedFile,
|
||||
message.id,
|
||||
oldFileMetadata!.id,
|
||||
conversationJid,
|
||||
_getMimeGuess(event),
|
||||
shouldShowNotification: false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (fileMetadata != null) {
|
||||
_log.info('Not downloading file as we already have it locally');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_log.warning(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
class XmppStateService {
|
||||
/// Persistent state around the connection, like the SM token, etc.
|
||||
@@ -9,13 +11,29 @@ class XmppStateService {
|
||||
Future<XmppState> getXmppState() async {
|
||||
if (_state != null) return _state!;
|
||||
|
||||
_state = await GetIt.I.get<DatabaseService>().getXmppState();
|
||||
final json = <String, String?>{};
|
||||
final rowsRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(xmppStateTable);
|
||||
for (final row in rowsRaw) {
|
||||
json[row['key']! as String] = row['value'] as String?;
|
||||
}
|
||||
|
||||
_state = XmppState.fromDatabaseTuples(json);
|
||||
return _state!;
|
||||
}
|
||||
|
||||
/// A wrapper to modify the [XmppState] and commit it.
|
||||
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
||||
_state = func(_state!);
|
||||
await GetIt.I.get<DatabaseService>().saveXmppState(_state!);
|
||||
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
for (final tuple in _state!.toDatabaseTuples().entries) {
|
||||
batch.insert(
|
||||
xmppStateTable,
|
||||
<String, String?>{'key': tuple.key, 'value': tuple.value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:moxlib/awaitabledatasender.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
|
||||
part 'commands.moxxy.dart';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:moxlib/awaitabledatasender.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction_group.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
|
||||
@@ -58,7 +57,6 @@ class Conversation with _$Conversation {
|
||||
ConversationType type,
|
||||
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
||||
int lastChangeTimestamp,
|
||||
List<SharedMedium> sharedMedia,
|
||||
// Indicates if the conversation should be shown on the homescreen
|
||||
bool open,
|
||||
// Indicates, if [jid] is a regular user, if the user is in the roster.
|
||||
@@ -70,9 +68,7 @@ class Conversation with _$Conversation {
|
||||
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||
bool encrypted,
|
||||
// The current chat state
|
||||
@ConversationChatStateConverter() ChatState chatState,
|
||||
// The amount of shared media items that are in the database
|
||||
int sharedMediaAmount, {
|
||||
@ConversationChatStateConverter() ChatState chatState, {
|
||||
// The id of the contact in the device's phonebook if it exists
|
||||
String? contactId,
|
||||
// The path to the contact avatar, if available
|
||||
@@ -91,12 +87,10 @@ class Conversation with _$Conversation {
|
||||
Map<String, dynamic> json,
|
||||
bool inRoster,
|
||||
String subscription,
|
||||
List<SharedMedium> sharedMedia,
|
||||
Message? lastMessage,
|
||||
) {
|
||||
return Conversation.fromJson({
|
||||
...json,
|
||||
'sharedMedia': <Map<String, dynamic>>[],
|
||||
'muted': intToBool(json['muted']! as int),
|
||||
'open': intToBool(json['open']! as int),
|
||||
'inRoster': inRoster,
|
||||
@@ -106,7 +100,6 @@ class Conversation with _$Conversation {
|
||||
const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||
}).copyWith(
|
||||
lastMessage: lastMessage,
|
||||
sharedMedia: sharedMedia,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +107,6 @@ class Conversation with _$Conversation {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('chatState')
|
||||
..remove('sharedMedia')
|
||||
..remove('inRoster')
|
||||
..remove('subscription')
|
||||
..remove('lastMessage');
|
||||
@@ -152,6 +144,9 @@ class Conversation with _$Conversation {
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
/// The amount of items that are shown in the context menu.
|
||||
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
/// Sorts conversations in descending order by their last change timestamp.
|
||||
|
||||
120
lib/shared/models/file_metadata.dart
Normal file
120
lib/shared/models/file_metadata.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
|
||||
part 'file_metadata.freezed.dart';
|
||||
part 'file_metadata.g.dart';
|
||||
|
||||
/// Wrapper for turning a map "Hash algorithm -> Hash value" [hashes] into a string
|
||||
/// for storage in the database.
|
||||
String serializeHashMap(Map<HashFunction, String> hashes) {
|
||||
final rawMap =
|
||||
hashes.map((key, value) => MapEntry<String, String>(key.toName(), value));
|
||||
return jsonEncode(rawMap);
|
||||
}
|
||||
|
||||
/// Wrapper for turning a string [hashString] into a map "Hash algorithm -> Hash value".
|
||||
Map<HashFunction, String> deserializeHashMap(String hashString) {
|
||||
final rawMap =
|
||||
(jsonDecode(hashString) as Map<dynamic, dynamic>).cast<String, String>();
|
||||
return rawMap.map(
|
||||
(key, value) =>
|
||||
MapEntry<HashFunction, String>(HashFunction.fromName(key), value),
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class FileMetadata with _$FileMetadata {
|
||||
factory FileMetadata(
|
||||
/// A unique ID
|
||||
String id,
|
||||
|
||||
/// The path where the file can be found.
|
||||
String? path,
|
||||
|
||||
/// The source where the file came from.
|
||||
List<String>? sourceUrls,
|
||||
|
||||
/// The MIME type of the media, if available.
|
||||
String? mimeType,
|
||||
|
||||
/// The size in bytes of the file, if available.
|
||||
int? size,
|
||||
|
||||
/// The type of thumbnail data we have, if [thumbnailData] is non-null.
|
||||
String? thumbnailType,
|
||||
|
||||
/// String-encodable thumbnail data, like blurhash.
|
||||
String? thumbnailData,
|
||||
|
||||
/// Media dimensions, if the media file has such attributes.
|
||||
int? width,
|
||||
int? height,
|
||||
|
||||
/// A list of hashes for the original plaintext file.
|
||||
Map<HashFunction, String>? plaintextHashes,
|
||||
|
||||
/// If non-null: The key the file was encrypted with.
|
||||
String? encryptionKey,
|
||||
|
||||
/// If non-null: The IV used for encryption.
|
||||
String? encryptionIv,
|
||||
|
||||
/// If non-null: The encryption method used for encrypting the file.
|
||||
String? encryptionScheme,
|
||||
|
||||
/// A list of hashes for the encrypted file.
|
||||
Map<HashFunction, String>? ciphertextHashes,
|
||||
|
||||
/// The actual filename of the file. If the filename was obfuscated, e.g. due
|
||||
/// to encryption, this should be the original filename.
|
||||
String filename,
|
||||
) = _FileMetadata;
|
||||
const FileMetadata._();
|
||||
|
||||
/// JSON
|
||||
factory FileMetadata.fromJson(Map<String, dynamic> json) =>
|
||||
_$FileMetadataFromJson(json);
|
||||
|
||||
factory FileMetadata.fromDatabaseJson(Map<String, dynamic> json) {
|
||||
final plaintextHashesRaw = json['plaintextHashes'] as String?;
|
||||
final plaintextHashes = plaintextHashesRaw != null
|
||||
? deserializeHashMap(plaintextHashesRaw)
|
||||
: null;
|
||||
final ciphertextHashesRaw = json['ciphertextHashes'] as String?;
|
||||
final ciphertextHashes = ciphertextHashesRaw != null
|
||||
? deserializeHashMap(ciphertextHashesRaw)
|
||||
: null;
|
||||
final sourceUrlsRaw = json['sourceUrls'] as String?;
|
||||
final sourceUrls = sourceUrlsRaw == null
|
||||
? null
|
||||
: (jsonDecode(sourceUrlsRaw) as List<dynamic>).cast<String>();
|
||||
|
||||
// Workaround for using enums as map keys
|
||||
final modifiedJson = Map<String, dynamic>.from(json)
|
||||
..remove('plaintextHashes')
|
||||
..remove('ciphertextHashes');
|
||||
return FileMetadata.fromJson({
|
||||
...modifiedJson,
|
||||
'sourceUrls': sourceUrls,
|
||||
}).copyWith(
|
||||
plaintextHashes: plaintextHashes,
|
||||
ciphertextHashes: ciphertextHashes,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final map = toJson()
|
||||
..remove('plaintextHashes')
|
||||
..remove('ciphertextHashes')
|
||||
..remove('sourceUrls');
|
||||
return {
|
||||
...map,
|
||||
'plaintextHashes':
|
||||
plaintextHashes != null ? serializeHashMap(plaintextHashes!) : null,
|
||||
'ciphertextHashes':
|
||||
ciphertextHashes != null ? serializeHashMap(ciphertextHashes!) : null,
|
||||
'sourceUrls': sourceUrls != null ? jsonEncode(sourceUrls) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'media.freezed.dart';
|
||||
part 'media.g.dart';
|
||||
|
||||
@freezed
|
||||
class SharedMedium with _$SharedMedium {
|
||||
factory SharedMedium(
|
||||
int id,
|
||||
String path,
|
||||
int timestamp,
|
||||
String conversationJid, {
|
||||
String? mime,
|
||||
int? messageId,
|
||||
}) = _SharedMedia;
|
||||
|
||||
const SharedMedium._();
|
||||
|
||||
/// JSON
|
||||
factory SharedMedium.fromJson(Map<String, dynamic> json) =>
|
||||
_$SharedMediumFromJson(json);
|
||||
|
||||
factory SharedMedium.fromDatabaseJson(Map<String, dynamic> json) {
|
||||
return SharedMedium.fromJson({
|
||||
...json,
|
||||
'messageId': json['message_id'] as int?,
|
||||
'conversationJid': json['conversation_jid']! as String,
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
return {
|
||||
...toJson()
|
||||
..remove('id')
|
||||
..remove('messageId')
|
||||
..remove('conversationJid'),
|
||||
'message_id': messageId,
|
||||
'conversation_jid': conversationJid,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
|
||||
part 'message.freezed.dart';
|
||||
@@ -11,24 +11,12 @@ part 'message.g.dart';
|
||||
|
||||
const pseudoMessageTypeNewDevice = 1;
|
||||
|
||||
Map<String, String>? _optionalJsonDecode(String? data) {
|
||||
if (data == null) return null;
|
||||
|
||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
|
||||
if (data == null) return <String, dynamic>{};
|
||||
|
||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
||||
}
|
||||
|
||||
String? _optionalJsonEncode(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
if (data.isEmpty) return null;
|
||||
@@ -46,26 +34,15 @@ class Message with _$Message {
|
||||
// The database-internal identifier of the message
|
||||
int id,
|
||||
String conversationJid,
|
||||
// True if the message contains some embedded media
|
||||
bool isMedia,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||
bool containsNoStore, {
|
||||
int? errorType,
|
||||
int? warningType,
|
||||
String? mediaUrl,
|
||||
FileMetadata? fileMetadata,
|
||||
@Default(false) bool isDownloading,
|
||||
@Default(false) bool isUploading,
|
||||
String? mediaType,
|
||||
String? thumbnailData,
|
||||
int? mediaWidth,
|
||||
int? mediaHeight,
|
||||
// If non-null: Indicates where some media entry originated/originates from
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
String? iv,
|
||||
String? encryptionScheme,
|
||||
@Default(false) bool received,
|
||||
@Default(false) bool displayed,
|
||||
@Default(false) bool acked,
|
||||
@@ -73,13 +50,8 @@ class Message with _$Message {
|
||||
@Default(false) bool isEdited,
|
||||
String? originId,
|
||||
Message? quotes,
|
||||
String? filename,
|
||||
Map<String, String>? plaintextHashes,
|
||||
Map<String, String>? ciphertextHashes,
|
||||
int? mediaSize,
|
||||
@Default([]) List<Reaction> reactions,
|
||||
@Default([]) List<String> reactionsPreview,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}) = _Message;
|
||||
@@ -90,34 +62,31 @@ class Message with _$Message {
|
||||
factory Message.fromJson(Map<String, dynamic> json) =>
|
||||
_$MessageFromJson(json);
|
||||
|
||||
factory Message.fromDatabaseJson(Map<String, dynamic> json, Message? quotes) {
|
||||
factory Message.fromDatabaseJson(
|
||||
Map<String, dynamic> json,
|
||||
Message? quotes,
|
||||
FileMetadata? fileMetadata,
|
||||
List<String> reactionsPreview,
|
||||
) {
|
||||
return Message.fromJson({
|
||||
...json,
|
||||
'received': intToBool(json['received']! as int),
|
||||
'displayed': intToBool(json['displayed']! as int),
|
||||
'acked': intToBool(json['acked']! as int),
|
||||
'isMedia': intToBool(json['isMedia']! as int),
|
||||
'isFileUploadNotification':
|
||||
intToBool(json['isFileUploadNotification']! as int),
|
||||
'encrypted': intToBool(json['encrypted']! as int),
|
||||
'plaintextHashes':
|
||||
_optionalJsonDecode(json['plaintextHashes'] as String?),
|
||||
'ciphertextHashes':
|
||||
_optionalJsonDecode(json['ciphertextHashes'] as String?),
|
||||
'isDownloading': intToBool(json['isDownloading']! as int),
|
||||
'isUploading': intToBool(json['isUploading']! as int),
|
||||
'isRetracted': intToBool(json['isRetracted']! as int),
|
||||
'isEdited': intToBool(json['isEdited']! as int),
|
||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||
'reactions': <Map<String, dynamic>>[],
|
||||
'reactionsPreview': reactionsPreview,
|
||||
'pseudoMessageData':
|
||||
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
||||
}).copyWith(
|
||||
quotes: quotes,
|
||||
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map<Reaction>(Reaction.fromJson)
|
||||
.toList(),
|
||||
fileMetadata: fileMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,29 +94,25 @@ class Message with _$Message {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('quotes')
|
||||
..remove('reactions')
|
||||
..remove('reactionsPreview')
|
||||
..remove('fileMetadata')
|
||||
..remove('pseudoMessageData');
|
||||
|
||||
return {
|
||||
...map,
|
||||
'isMedia': boolToInt(isMedia),
|
||||
'isFileUploadNotification': boolToInt(isFileUploadNotification),
|
||||
'received': boolToInt(received),
|
||||
'displayed': boolToInt(displayed),
|
||||
'acked': boolToInt(acked),
|
||||
'encrypted': boolToInt(encrypted),
|
||||
'file_metadata_id': fileMetadata?.id,
|
||||
// NOTE: Message.quote_id is a foreign-key
|
||||
'quote_id': quotes?.id,
|
||||
'plaintextHashes': _optionalJsonEncode(plaintextHashes),
|
||||
'ciphertextHashes': _optionalJsonEncode(ciphertextHashes),
|
||||
'isDownloading': boolToInt(isDownloading),
|
||||
'isUploading': boolToInt(isUploading),
|
||||
'isRetracted': boolToInt(isRetracted),
|
||||
'isEdited': boolToInt(isEdited),
|
||||
'containsNoStore': boolToInt(containsNoStore),
|
||||
'reactions': jsonEncode(
|
||||
reactions.map((r) => r.toJson()).toList(),
|
||||
),
|
||||
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
||||
};
|
||||
}
|
||||
@@ -161,7 +126,7 @@ class Message with _$Message {
|
||||
/// Returns a representative emoji for a message. Its primary purpose is
|
||||
/// to provide a universal fallback for quoted media messages.
|
||||
String get messageEmoji {
|
||||
return mimeTypeToEmoji(mediaType, addTypeName: false);
|
||||
return mimeTypeToEmoji(fileMetadata?.mimeType, addTypeName: false);
|
||||
}
|
||||
|
||||
/// True if the message is a pseudo message.
|
||||
@@ -224,19 +189,21 @@ class Message with _$Message {
|
||||
|
||||
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||
/// images.
|
||||
bool get isThumbnailable =>
|
||||
!isPseudoMessage &&
|
||||
isMedia &&
|
||||
mediaType != null &&
|
||||
(mediaType!.startsWith('image/') || mediaType!.startsWith('video/'));
|
||||
bool get isThumbnailable {
|
||||
if (isPseudoMessage || !isMedia || fileMetadata?.mimeType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final mimeType = fileMetadata!.mimeType!;
|
||||
return mimeType.startsWith('image/') || mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
/// Returns true if the message can be copied to the clipboard.
|
||||
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the message is a sticker
|
||||
bool get isSticker =>
|
||||
isMedia &&
|
||||
stickerPackId != null &&
|
||||
stickerHashKey != null &&
|
||||
!isPseudoMessage;
|
||||
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
|
||||
|
||||
/// True if the message is a media message
|
||||
bool get isMedia => fileMetadata != null;
|
||||
}
|
||||
|
||||
@@ -6,22 +6,14 @@ part 'reaction.g.dart';
|
||||
@freezed
|
||||
class Reaction with _$Reaction {
|
||||
factory Reaction(
|
||||
List<String> senders,
|
||||
// This is valid in combination with freezed
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(name: 'message_id') int messageId,
|
||||
String senderJid,
|
||||
String emoji,
|
||||
// NOTE: Store this with the model to prevent having to to a O(n) search across the
|
||||
// list of reactions on every rebuild
|
||||
bool reactedBySelf,
|
||||
) = _Reaction;
|
||||
|
||||
const Reaction._();
|
||||
|
||||
/// JSON
|
||||
factory Reaction.fromJson(Map<String, dynamic> json) =>
|
||||
_$ReactionFromJson(json);
|
||||
|
||||
int get reactions {
|
||||
if (reactedBySelf) return senders.length + 1;
|
||||
|
||||
return senders.length;
|
||||
}
|
||||
}
|
||||
|
||||
16
lib/shared/models/reaction_group.dart
Normal file
16
lib/shared/models/reaction_group.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'reaction_group.freezed.dart';
|
||||
part 'reaction_group.g.dart';
|
||||
|
||||
@freezed
|
||||
class ReactionGroup with _$ReactionGroup {
|
||||
factory ReactionGroup(
|
||||
String jid,
|
||||
List<String> emojis,
|
||||
) = _ReactionGroup;
|
||||
|
||||
/// JSON
|
||||
factory ReactionGroup.fromJson(Map<String, dynamic> json) =>
|
||||
_$ReactionGroupFromJson(json);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
part 'sticker.freezed.dart';
|
||||
part 'sticker.g.dart';
|
||||
@@ -9,84 +12,89 @@ part 'sticker.g.dart';
|
||||
@freezed
|
||||
class Sticker with _$Sticker {
|
||||
factory Sticker(
|
||||
String hashKey,
|
||||
String mediaType,
|
||||
String desc,
|
||||
int size,
|
||||
int? width,
|
||||
int? height,
|
||||
|
||||
/// Hash algorithm (algo attribute) -> Base64 encoded hash
|
||||
Map<String, String> hashes,
|
||||
List<String> urlSources,
|
||||
String path,
|
||||
String id,
|
||||
String stickerPackId,
|
||||
String desc,
|
||||
Map<String, String> suggests,
|
||||
FileMetadata fileMetadata,
|
||||
) = _Sticker;
|
||||
|
||||
const Sticker._();
|
||||
|
||||
/// Moxxmpp
|
||||
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) =>
|
||||
Sticker(
|
||||
getStickerHashKey(sticker.metadata.hashes),
|
||||
sticker.metadata.mediaType!,
|
||||
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) {
|
||||
final hashKey = getStickerHashKey(sticker.metadata.hashes);
|
||||
final firstUrl = (sticker.sources.firstWhereOrNull(
|
||||
(src) => src is moxxmpp.StatelessFileSharingUrlSource,
|
||||
)! as moxxmpp.StatelessFileSharingUrlSource)
|
||||
.url;
|
||||
return Sticker(
|
||||
hashKey,
|
||||
stickerPackId,
|
||||
sticker.metadata.desc!,
|
||||
sticker.metadata.size!,
|
||||
sticker.metadata.width,
|
||||
sticker.metadata.height,
|
||||
sticker.metadata.hashes,
|
||||
sticker.suggests,
|
||||
FileMetadata(
|
||||
hashKey,
|
||||
null,
|
||||
sticker.sources
|
||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList(),
|
||||
'',
|
||||
stickerPackId,
|
||||
sticker.suggests,
|
||||
sticker.metadata.mediaType,
|
||||
sticker.metadata.size,
|
||||
null,
|
||||
null,
|
||||
sticker.metadata.width,
|
||||
sticker.metadata.height,
|
||||
sticker.metadata.hashes,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.metadata.name ?? path.basename(firstUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON
|
||||
factory Sticker.fromJson(Map<String, dynamic> json) =>
|
||||
_$StickerFromJson(json);
|
||||
|
||||
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) {
|
||||
factory Sticker.fromDatabaseJson(
|
||||
Map<String, dynamic> json,
|
||||
FileMetadata fileMetadata,
|
||||
) {
|
||||
return Sticker.fromJson({
|
||||
...json,
|
||||
'hashes': (jsonDecode(json['hashes']! as String) as Map<dynamic, dynamic>)
|
||||
.cast<String, String>(),
|
||||
'urlSources': (jsonDecode(json['urlSources']! as String) as List<dynamic>)
|
||||
.cast<String>(),
|
||||
'suggests':
|
||||
(jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>)
|
||||
.cast<String, String>(),
|
||||
'fileMetadata': fileMetadata.toJson(),
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final map = toJson()
|
||||
..remove('hashes')
|
||||
..remove('urlSources')
|
||||
..remove('suggests');
|
||||
final map = toJson()..remove('fileMetadata');
|
||||
|
||||
return {
|
||||
...map,
|
||||
'hashes': jsonEncode(hashes),
|
||||
'urlSources': jsonEncode(urlSources),
|
||||
'suggests': jsonEncode(suggests),
|
||||
'file_metadata_id': fileMetadata.id,
|
||||
};
|
||||
}
|
||||
|
||||
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
|
||||
moxxmpp.FileMetadataData(
|
||||
mediaType: mediaType,
|
||||
mediaType: fileMetadata.mimeType,
|
||||
desc: desc,
|
||||
size: size,
|
||||
width: width,
|
||||
height: height,
|
||||
size: fileMetadata.size,
|
||||
width: fileMetadata.width,
|
||||
height: fileMetadata.height,
|
||||
thumbnails: [],
|
||||
hashes: hashes,
|
||||
hashes: fileMetadata.plaintextHashes,
|
||||
),
|
||||
urlSources
|
||||
fileMetadata.sourceUrls!
|
||||
// Dart has some issues with using a constructor in a map
|
||||
// ignore: unnecessary_lambdas
|
||||
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
|
||||
.toList(),
|
||||
@@ -94,5 +102,5 @@ class Sticker with _$Sticker {
|
||||
);
|
||||
|
||||
/// True, if the sticker is backed by an image with MIME type image/*.
|
||||
bool get isImage => mediaType.startsWith('image/');
|
||||
bool get isImage => fileMetadata.mimeType?.startsWith('image/') ?? false;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class StickerPack with _$StickerPack {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
moxxmpp.hashFunctionFromName(hashAlgorithm),
|
||||
moxxmpp.HashFunction.fromName(hashAlgorithm),
|
||||
hashValue,
|
||||
stickers.map((sticker) => sticker.toMoxxmpp()).toList(),
|
||||
restricted,
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
part 'conversation_bloc.freezed.dart';
|
||||
part 'conversation_event.dart';
|
||||
@@ -36,19 +28,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||
on<OmemoSetEvent>(_onOmemoSet);
|
||||
on<SendButtonDragStartedEvent>(_onDragStarted);
|
||||
on<SendButtonDragEndedEvent>(_onDragEnded);
|
||||
on<SendButtonLockedEvent>(_onSendButtonLocked);
|
||||
on<SendButtonLockPressedEvent>(_onSendButtonLockPressed);
|
||||
on<RecordingCanceledEvent>(_onRecordingCanceled);
|
||||
|
||||
_audioRecorder = Record();
|
||||
}
|
||||
|
||||
/// The audio recorder
|
||||
late Record _audioRecorder;
|
||||
DateTime? _recordingStart;
|
||||
|
||||
bool _isSameConversation(String jid) => jid == state.conversation?.jid;
|
||||
|
||||
Future<void> _onInit(
|
||||
@@ -135,14 +116,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
CurrentConversationResetEvent event,
|
||||
Emitter<ConversationState> emit,
|
||||
) async {
|
||||
// Reset conversation so that we don't accidentally send chat states to chats
|
||||
// that are not currently focused.
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversation: null,
|
||||
),
|
||||
);
|
||||
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SetOpenConversationCommand(),
|
||||
awaitable: false,
|
||||
@@ -209,128 +182,4 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDragStarted(
|
||||
SendButtonDragStartedEvent event,
|
||||
Emitter<ConversationState> emit,
|
||||
) async {
|
||||
final status = await Permission.speech.status;
|
||||
if (status.isDenied) {
|
||||
await Permission.speech.request();
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isDragging: true,
|
||||
isRecording: true,
|
||||
),
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
_recordingStart = now;
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp =
|
||||
'${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
|
||||
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
|
||||
await _audioRecorder.start(
|
||||
path: tempFile,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRecordingEnd() async {
|
||||
// Prevent messages of really short duration being sent
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_recordingStart!).inSeconds < 1) {
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.warnings.conversation.holdForLonger,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if something unexpected happened
|
||||
final recordingPath = await _audioRecorder.stop();
|
||||
if (recordingPath == null) {
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.errors.conversation.audioRecordingError,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the file
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendFilesCommand(
|
||||
paths: [recordingPath],
|
||||
recipients: [state.conversation!.jid],
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDragEnded(
|
||||
SendButtonDragEndedEvent event,
|
||||
Emitter<ConversationState> emit,
|
||||
) async {
|
||||
final recording = state.isRecording;
|
||||
emit(
|
||||
state.copyWith(
|
||||
isDragging: false,
|
||||
isLocked: false,
|
||||
isRecording: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (recording) {
|
||||
await _handleRecordingEnd();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSendButtonLocked(
|
||||
SendButtonLockedEvent event,
|
||||
Emitter<ConversationState> emit,
|
||||
) async {
|
||||
Vibrate.feedback(FeedbackType.light);
|
||||
|
||||
emit(state.copyWith(isLocked: true));
|
||||
}
|
||||
|
||||
Future<void> _onSendButtonLockPressed(
|
||||
SendButtonLockPressedEvent event,
|
||||
Emitter<ConversationState> emit,
|
||||
) async {
|
||||
final recording = state.isRecording;
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLocked: false,
|
||||
isDragging: false,
|
||||
isRecording: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (recording) {
|
||||
await _handleRecordingEnd();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRecordingCanceled(
|
||||
RecordingCanceledEvent event,
|
||||
Emitter<ConversationState> emit,
|
||||
) async {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLocked: false,
|
||||
isDragging: false,
|
||||
isRecording: false,
|
||||
),
|
||||
);
|
||||
|
||||
final file = await _audioRecorder.stop();
|
||||
unawaited(File(file!).delete());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
part of 'conversation_bloc.dart';
|
||||
|
||||
enum SendButtonState {
|
||||
/// Open the speed dial when tapped.
|
||||
multi,
|
||||
|
||||
/// Send the current message when tapped.
|
||||
send,
|
||||
|
||||
/// Cancel the current correction when tapped.
|
||||
cancelCorrection,
|
||||
|
||||
/// Hide the button when we're recording an audio message.
|
||||
hidden,
|
||||
}
|
||||
|
||||
const defaultSendButtonState = SendButtonState.multi;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
@@ -147,4 +148,10 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Return, if existent, the conversation from the state with a JID equal to [jid].
|
||||
/// Returns null, if the conversation does not exist.
|
||||
Conversation? getConversationByJid(String jid) {
|
||||
return state.conversations.firstWhereOrNull((c) => c.jid == jid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/profile.dart';
|
||||
|
||||
part 'profile_bloc.freezed.dart';
|
||||
part 'profile_event.dart';
|
||||
@@ -28,7 +29,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
if (event.isSelfProfile) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSelfProfile: true,
|
||||
jid: event.jid!,
|
||||
avatarUrl: event.avatarUrl!,
|
||||
displayName: event.displayName!,
|
||||
@@ -37,7 +37,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSelfProfile: false,
|
||||
conversation: event.conversation,
|
||||
),
|
||||
);
|
||||
@@ -45,8 +44,12 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedEvent(
|
||||
const NavigationDestination(
|
||||
NavigationDestination(
|
||||
profileRoute,
|
||||
arguments: ProfileArguments(
|
||||
event.isSelfProfile,
|
||||
event.jid ?? event.conversation!.jid,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
for (final sticker in pack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
map[StickerKey(pack.id, sticker.hashKey)] = sticker;
|
||||
map[StickerKey(pack.id, sticker.id)] = sticker;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
)!;
|
||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
||||
for (final sticker in stickerPack.stickers) {
|
||||
sm.remove(StickerKey(stickerPack.id, sticker.hashKey));
|
||||
sm.remove(StickerKey(stickerPack.id, sticker.id));
|
||||
|
||||
// Evict stickers from the cache
|
||||
unawaited(FileImage(File(sticker.path)).evict());
|
||||
unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
|
||||
}
|
||||
|
||||
emit(
|
||||
@@ -105,7 +105,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
for (final sticker in result.stickerPack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker;
|
||||
sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -146,7 +146,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
for (final sticker in event.stickerPack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker;
|
||||
sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
|
||||
}
|
||||
|
||||
emit(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const Radius radiusLarge = Radius.circular(10);
|
||||
const double radiusLargeSize = 10;
|
||||
const Radius radiusLarge = Radius.circular(radiusLargeSize);
|
||||
const Radius radiusSmall = Radius.circular(4);
|
||||
|
||||
const double textfieldRadiusRegular = 15;
|
||||
const double textfieldRadiusConversation = 25;
|
||||
const double textfieldQuotedMessageRadius = textfieldRadiusConversation - 10;
|
||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
@@ -93,6 +95,9 @@ const Color profileFallbackTextColorDark = Colors.white;
|
||||
/// The text color of the buttons in the overlay of the ConversationPage
|
||||
const Color conversationOverlayButtonTextColor = Color(0xffcf4aff);
|
||||
|
||||
/// The background color of the context menu
|
||||
const Color contextMenuBackgroundColor = Color(0xff515151);
|
||||
|
||||
const Color settingsSectionTitleColor = Color(0xffb72fe7);
|
||||
|
||||
const double paddingVeryLarge = 64;
|
||||
@@ -117,9 +122,18 @@ final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
|
||||
/// displaying the download progress indicator.
|
||||
final backdropBlack = Colors.black.withAlpha(150);
|
||||
|
||||
/// The height of the emoji/sticker picker
|
||||
/// The height of the emoji/sticker picker.
|
||||
const double pickerHeight = 300;
|
||||
|
||||
/// The color of a reaction that is not from ourselves.
|
||||
const Color reactionColorReceived = Color(0xff757575);
|
||||
|
||||
/// The color of a reaction that is sent by ourselves.
|
||||
const Color reactionColorSent = Color(0xff2993FB);
|
||||
|
||||
/// The color of the skim when a message is highlighted.
|
||||
const Color highlightSkimColor = Color(0xff000000);
|
||||
|
||||
/// Navigation constants
|
||||
const String cropRoute = '/crop';
|
||||
const String introRoute = '/intro';
|
||||
|
||||
@@ -29,10 +29,10 @@ class BidirectionalController<T> {
|
||||
/// _cache.length - 1: The newest data item we know about
|
||||
final List<T> _cache = List<T>.empty(growable: true);
|
||||
final StreamController<List<T>> _dataStreamController =
|
||||
StreamController<List<T>>();
|
||||
StreamController<List<T>>.broadcast();
|
||||
Stream<List<T>> get dataStream => _dataStreamController.stream;
|
||||
|
||||
@protected
|
||||
//@protected
|
||||
List<T> get cache => _cache;
|
||||
|
||||
/// True if the cache has exceeded the size limit of pageSize * maxPageAmount.
|
||||
@@ -40,8 +40,9 @@ class BidirectionalController<T> {
|
||||
|
||||
/// Flag indicating whether we are currently fetching data
|
||||
bool _isFetching = false;
|
||||
bool get isFetching => _isFetching;
|
||||
final StreamController<bool> _isFetchingStreamController =
|
||||
StreamController<bool>();
|
||||
StreamController<bool>.broadcast();
|
||||
Stream<bool> get isFetchingStream => _isFetchingStreamController.stream;
|
||||
|
||||
/// Flag indicating whether we are able to request newer data
|
||||
@@ -53,7 +54,6 @@ class BidirectionalController<T> {
|
||||
bool hasOlderData = true;
|
||||
|
||||
/// Flag indicating whether data has been loaded at least once
|
||||
@protected
|
||||
bool hasFetchedOnce = false;
|
||||
|
||||
/// True if we are scrolled to the bottom of the view. False, otherwise.
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
|
||||
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
class MessageEditingState {
|
||||
const MessageEditingState(
|
||||
@@ -37,7 +44,6 @@ class TextFieldData {
|
||||
const TextFieldData(
|
||||
this.isBodyEmpty,
|
||||
this.quotedMessage,
|
||||
this.pickerVisible,
|
||||
);
|
||||
|
||||
/// Flag indicating whether the current text input is empty.
|
||||
@@ -45,9 +51,19 @@ class TextFieldData {
|
||||
|
||||
/// The currently quoted message.
|
||||
final Message? quotedMessage;
|
||||
}
|
||||
|
||||
/// Flag indicating whether the picker is currently open or not.
|
||||
final bool pickerVisible;
|
||||
class RecordingData {
|
||||
const RecordingData(
|
||||
this.isRecording,
|
||||
this.isLocked,
|
||||
);
|
||||
|
||||
/// Flag indicating whether we are currently recording (true) or not (false).
|
||||
final bool isRecording;
|
||||
|
||||
/// Flag indicating whether the recording draggable is locked (true) or not (false).
|
||||
final bool isLocked;
|
||||
}
|
||||
|
||||
class BidirectionalConversationController
|
||||
@@ -62,21 +78,19 @@ class BidirectionalConversationController
|
||||
maxPageAmount: maxMessagePages,
|
||||
) {
|
||||
_textController.addListener(_handleTextChanged);
|
||||
_keyboardVisibilitySubscription = KeyboardVisibilityController()
|
||||
.onChange
|
||||
.listen(_handleSoftKeyboardVisibilityChanged);
|
||||
|
||||
BidirectionalConversationController.currentController = this;
|
||||
|
||||
_updateChatState(ChatState.active);
|
||||
}
|
||||
|
||||
/// Logging.
|
||||
final Logger _log = Logger('BidirectionalConversationController');
|
||||
|
||||
/// A singleton referring to the current instance as there can only be one
|
||||
/// BidirectionalConversationController at a time.
|
||||
static BidirectionalConversationController? currentController;
|
||||
|
||||
late final StreamSubscription<bool> _keyboardVisibilitySubscription;
|
||||
|
||||
/// TextEditingController for the TextField
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
TextEditingController get textController => _textController;
|
||||
@@ -109,18 +123,21 @@ class BidirectionalConversationController
|
||||
Stream<TextFieldData> get textFieldDataStream =>
|
||||
_textFieldDataStreamController.stream;
|
||||
|
||||
/// Flag indicating whether the (emoji/sticker) picker is visible
|
||||
bool _pickerVisible = false;
|
||||
final StreamController<bool> _pickerVisibleStreamController =
|
||||
StreamController.broadcast();
|
||||
Stream<bool> get pickerVisibleStream => _pickerVisibleStreamController.stream;
|
||||
|
||||
/// The timer for managing the "compose" state
|
||||
Timer? _composeTimer;
|
||||
|
||||
/// The last time the TextField was modified
|
||||
int _lastChangeTimestamp = 0;
|
||||
|
||||
/// Flag indicating whether we are currently recording an audio message (true) or not
|
||||
/// (false).
|
||||
final Record _audioRecorder = Record();
|
||||
DateTime? _recordingStart;
|
||||
final StreamController<RecordingData> _recordingAudioMessageStreamController =
|
||||
StreamController<RecordingData>.broadcast();
|
||||
Stream<RecordingData> get recordingAudioMessageStream =>
|
||||
_recordingAudioMessageStreamController.stream;
|
||||
|
||||
void _updateChatState(ChatState state) {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendChatStateCommand(
|
||||
@@ -155,12 +172,6 @@ class BidirectionalConversationController
|
||||
_composeTimer = null;
|
||||
}
|
||||
|
||||
void _handleSoftKeyboardVisibilityChanged(bool visible) {
|
||||
if (visible && _pickerVisible) {
|
||||
togglePickerVisibility(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
final text = _textController.text;
|
||||
if (_messageEditingState != null) {
|
||||
@@ -181,7 +192,6 @@ class BidirectionalConversationController
|
||||
TextFieldData(
|
||||
messageBody.isEmpty,
|
||||
_quotedMessage,
|
||||
_pickerVisible,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -206,13 +216,28 @@ class BidirectionalConversationController
|
||||
|
||||
Future<void> onMessageReceived(Message message) async {
|
||||
// Drop the message if we don't really care about it
|
||||
if (message.conversationJid != conversationJid) return;
|
||||
if (message.conversationJid != conversationJid) {
|
||||
_log.finest(
|
||||
"Not processing message as JIDs don't match: ${message.conversationJid} != $conversationJid",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(Unknown): Guard against not being initialized yet, i.e. not having loaded the first
|
||||
// messages.
|
||||
// TODO(Unknown): This is probably not the best solution
|
||||
if (isFetching) {
|
||||
_log.finest('Not processing message as we are currently fetching');
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldScrollToBottom = true;
|
||||
if (message.timestamp < cache.last.timestamp) {
|
||||
if (cache.isEmpty && hasFetchedOnce) {
|
||||
// We do this check here to prevent a StateException being thrown because
|
||||
// the cache is empty. So just add the message.
|
||||
addItem(message);
|
||||
|
||||
// As this is the first message, we don't have to scroll to the bottom.
|
||||
shouldScrollToBottom = false;
|
||||
} else if (message.timestamp < cache.last.timestamp) {
|
||||
if (message.timestamp < cache.first.timestamp) {
|
||||
// The message is older than the oldest message we know about. Drop it.
|
||||
// It will be fetched when scrolling up.
|
||||
@@ -264,97 +289,19 @@ class BidirectionalConversationController
|
||||
);
|
||||
}
|
||||
|
||||
/// Add [emoji] as a reaction to the message at index [index].
|
||||
void addReaction(int index, String emoji) {
|
||||
final message = cache[index];
|
||||
final reactionIndex = message.reactions.indexWhere(
|
||||
(Reaction r) => r.emoji == emoji,
|
||||
);
|
||||
|
||||
if (reactionIndex != -1) {
|
||||
// Ignore the request when the reaction would be invalid
|
||||
final reaction = message.reactions[reactionIndex];
|
||||
if (reaction.reactedBySelf) return;
|
||||
|
||||
final reactions = List<Reaction>.from(message.reactions);
|
||||
reactions[reactionIndex] = reaction.copyWith(
|
||||
reactedBySelf: true,
|
||||
);
|
||||
cache[index] = cache[index].copyWith(
|
||||
reactions: reactions,
|
||||
);
|
||||
} else {
|
||||
// The reaction is new
|
||||
cache[index] = message.copyWith(
|
||||
reactions: [
|
||||
...message.reactions,
|
||||
Reaction(
|
||||
[],
|
||||
emoji,
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
forceUpdateUI();
|
||||
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
AddReactionToMessageCommand(
|
||||
messageId: message.id,
|
||||
emoji: emoji,
|
||||
conversationJid: conversationJid,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove the reaction [emoji] from the message at index [index].
|
||||
void removeReaction(int index, String emoji) {
|
||||
final message = cache[index];
|
||||
final reactionIndex = message.reactions.indexWhere(
|
||||
(Reaction r) => r.emoji == emoji,
|
||||
);
|
||||
|
||||
assert(reactionIndex >= 0, 'The reaction must be found');
|
||||
|
||||
final reactions = List<Reaction>.from(message.reactions);
|
||||
if (message.reactions[reactionIndex].senders.isEmpty) {
|
||||
reactions.removeAt(reactionIndex);
|
||||
} else {
|
||||
reactions[reactionIndex] = reactions[reactionIndex].copyWith(
|
||||
reactedBySelf: false,
|
||||
);
|
||||
}
|
||||
cache[index] = cache[index].copyWith(
|
||||
reactions: reactions,
|
||||
);
|
||||
|
||||
forceUpdateUI();
|
||||
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RemoveReactionFromMessageCommand(
|
||||
messageId: message.id,
|
||||
emoji: emoji,
|
||||
conversationJid: conversationJid,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Send the sticker identified by the Sticker pack [packId] and [hashKey].
|
||||
void sendSticker(String packId, String hashKey) {
|
||||
/// Send the sticker [sticker].
|
||||
void sendSticker(sticker.Sticker sticker) {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendStickerCommand(
|
||||
stickerPackId: packId,
|
||||
stickerHashKey: hashKey,
|
||||
sticker: sticker,
|
||||
recipient: conversationJid,
|
||||
quotes: _quotedMessage,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
|
||||
// Close the picker
|
||||
togglePickerVisibility(false);
|
||||
// Remove a possible quote
|
||||
removeQuote();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(bool encrypted) async {
|
||||
@@ -444,7 +391,6 @@ class BidirectionalConversationController
|
||||
TextFieldData(
|
||||
messageBody.isEmpty,
|
||||
message,
|
||||
_pickerVisible,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -456,7 +402,6 @@ class BidirectionalConversationController
|
||||
TextFieldData(
|
||||
messageBody.isEmpty,
|
||||
null,
|
||||
_pickerVisible,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -491,37 +436,104 @@ class BidirectionalConversationController
|
||||
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
||||
}
|
||||
|
||||
/// Toggles the visibility of the (emoji/sticker) picker
|
||||
void togglePickerVisibility(bool handleKeyboard) {
|
||||
final newState = !_pickerVisible;
|
||||
|
||||
if (handleKeyboard) {
|
||||
if (newState) {
|
||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
} else {
|
||||
SystemChannels.textInput.invokeMethod('TextInput.show');
|
||||
}
|
||||
Future<void> startAudioMessageRecording() async {
|
||||
final status = await Permission.speech.status;
|
||||
if (status.isDenied) {
|
||||
await Permission.speech.request();
|
||||
return;
|
||||
}
|
||||
|
||||
_pickerVisible = newState;
|
||||
_pickerVisibleStreamController.add(newState);
|
||||
_textFieldDataStreamController.add(
|
||||
TextFieldData(
|
||||
messageBody.isEmpty,
|
||||
_quotedMessage,
|
||||
newState,
|
||||
_recordingAudioMessageStreamController.add(
|
||||
const RecordingData(
|
||||
true,
|
||||
false,
|
||||
),
|
||||
);
|
||||
_sendButtonStreamController.add(conversation.SendButtonState.hidden);
|
||||
|
||||
final now = DateTime.now();
|
||||
_recordingStart = now;
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp =
|
||||
'${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
|
||||
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
|
||||
await _audioRecorder.start(
|
||||
path: tempFile,
|
||||
);
|
||||
}
|
||||
|
||||
void lockAudioMessageRecording() {
|
||||
_recordingAudioMessageStreamController.add(
|
||||
const RecordingData(
|
||||
true,
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// React to a onWillPop callback.
|
||||
bool handlePop() {
|
||||
if (_pickerVisible) {
|
||||
togglePickerVisibility(false);
|
||||
return false;
|
||||
Future<void> cancelAudioMessageRecording() async {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
_recordingAudioMessageStreamController.add(
|
||||
const RecordingData(
|
||||
false,
|
||||
false,
|
||||
),
|
||||
);
|
||||
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
||||
|
||||
_recordingStart = null;
|
||||
final file = await _audioRecorder.stop();
|
||||
unawaited(File(file!).delete());
|
||||
}
|
||||
|
||||
return true;
|
||||
Future<void> endAudioMessageRecording() async {
|
||||
_recordingAudioMessageStreamController.add(
|
||||
const RecordingData(
|
||||
false,
|
||||
false,
|
||||
),
|
||||
);
|
||||
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
||||
|
||||
if (_recordingStart == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
final file = await _audioRecorder.stop();
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_recordingStart!).inSeconds < 1) {
|
||||
_recordingStart = null;
|
||||
unawaited(File(file!).delete());
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.warnings.conversation.holdForLonger,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the recording timestamp
|
||||
_recordingStart = null;
|
||||
|
||||
// Handle something unexpected
|
||||
if (file == null) {
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.errors.conversation.audioRecordingError,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the file
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendFilesCommand(
|
||||
paths: [file],
|
||||
recipients: [conversationJid],
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// React to app livecycle changes
|
||||
@@ -533,11 +545,16 @@ class BidirectionalConversationController
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
// Reset the singleton
|
||||
BidirectionalConversationController.currentController = null;
|
||||
|
||||
// Dispose of controllers
|
||||
_textController.dispose();
|
||||
_keyboardVisibilitySubscription.cancel();
|
||||
_audioRecorder.dispose();
|
||||
|
||||
// Tell the contact that we're gone
|
||||
_updateChatState(ChatState.gone);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
|
||||
|
||||
class BidirectionalSharedMediaController
|
||||
extends BidirectionalController<SharedMedium> {
|
||||
extends BidirectionalController<Message> {
|
||||
BidirectionalSharedMediaController(this.conversationJid)
|
||||
: assert(
|
||||
BidirectionalSharedMediaController.currentController == null,
|
||||
@@ -24,11 +24,12 @@ class BidirectionalSharedMediaController
|
||||
/// BidirectionalConversationController at a time.
|
||||
static BidirectionalSharedMediaController? currentController;
|
||||
|
||||
/// The JID of the conversation we want to get shared media of.
|
||||
final String conversationJid;
|
||||
|
||||
@override
|
||||
Future<List<SharedMedium>> fetchOlderDataImpl(
|
||||
SharedMedium? oldestElement,
|
||||
Future<List<Message>> fetchOlderDataImpl(
|
||||
Message? oldestElement,
|
||||
) async {
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
@@ -37,14 +38,14 @@ class BidirectionalSharedMediaController
|
||||
timestamp: oldestElement?.timestamp,
|
||||
olderThan: true,
|
||||
),
|
||||
) as PagedSharedMediaResultEvent;
|
||||
) as PagedMessagesResultEvent;
|
||||
|
||||
return result.media;
|
||||
return result.messages;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SharedMedium>> fetchNewerDataImpl(
|
||||
SharedMedium? newestElement,
|
||||
Future<List<Message>> fetchNewerDataImpl(
|
||||
Message? newestElement,
|
||||
) async {
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
@@ -53,9 +54,9 @@ class BidirectionalSharedMediaController
|
||||
timestamp: newestElement?.timestamp,
|
||||
olderThan: false,
|
||||
),
|
||||
) as PagedSharedMediaResultEvent;
|
||||
) as PagedMessagesResultEvent;
|
||||
|
||||
return result.media;
|
||||
return result.messages;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:better_open_file/better_open_file.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
@@ -398,3 +399,47 @@ Future<void> openFile(String path) async {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a modal bottom sheet with an emoji picker. Resolves to the picked emoji,
|
||||
/// if one was picked. If the picker was dismissed, resolves to null.
|
||||
Future<String?> pickEmoji(BuildContext context, {bool pop = true}) async {
|
||||
final emoji = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
// TODO(PapaTutuWawa): Move this to the theme
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: radiusLarge,
|
||||
topRight: radiusLarge,
|
||||
),
|
||||
),
|
||||
builder: (context) => Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) {
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop(emoji.emoji);
|
||||
},
|
||||
//height: pickerHeight,
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (pop) {
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
return emoji;
|
||||
}
|
||||
|
||||
/// Compute the current position of the widget with the global key [key].
|
||||
Rect getWidgetPositionOnScreen(GlobalKey key) {
|
||||
// (See https://stackoverflow.com/questions/50316219/how-to-get-widgets-absolute-coordinates-on-a-screen-in-flutter/58788092#58788092)
|
||||
final renderObject = key.currentContext!.findRenderObject()!;
|
||||
final translation = renderObject.getTransformTo(null).getTranslation();
|
||||
final offset = Offset(translation.x, translation.y);
|
||||
return renderObject.paintBounds.shift(offset);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class AddContactPageState extends State<AddContactPage> {
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.addcontact.title),
|
||||
body: Column(
|
||||
children: [
|
||||
Visibility(
|
||||
|
||||
@@ -94,11 +94,9 @@ class BlocklistPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BlocklistBloc, BlocklistState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: BorderlessTopbar.simple(
|
||||
appBar: BorderlessTopbar.title(
|
||||
t.pages.blocklist.title,
|
||||
extra: [
|
||||
Expanded(child: Container()),
|
||||
PopupMenuButton(
|
||||
trailing: PopupMenuButton(
|
||||
onSelected: (BlocklistOptions result) async {
|
||||
if (result == BlocklistOptions.unblockAll) {
|
||||
final result = await showConfirmationDialog(
|
||||
@@ -124,8 +122,7 @@ class BlocklistPage extends StatelessWidget {
|
||||
child: Text(t.pages.blocklist.unblockAll),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _buildListView(state),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
@@ -11,37 +10,60 @@ import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/keyboard_dodging.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
class _TextFieldIconButton extends StatelessWidget {
|
||||
const _TextFieldIconButton(this.icon, this.onTap);
|
||||
final void Function() onTap;
|
||||
final IconData icon;
|
||||
const _TextFieldIconButton({
|
||||
required this.keyboardController,
|
||||
required this.tabController,
|
||||
required this.textfieldFocusNode,
|
||||
});
|
||||
|
||||
final KeyboardReplacerController keyboardController;
|
||||
final TabController tabController;
|
||||
|
||||
final FocusNode textfieldFocusNode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
keyboardController.toggleWidget(context, textfieldFocusNode);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
icon,
|
||||
child: StreamBuilder<KeyboardReplacerData>(
|
||||
stream: keyboardController.stream,
|
||||
initialData: keyboardController.currentData,
|
||||
builder: (context, snapshot) => Icon(
|
||||
snapshot.data!.showWidget
|
||||
? Icons.keyboard
|
||||
: (tabController.index == 0
|
||||
? Icons.insert_emoticon
|
||||
: PhosphorIcons.stickerBold),
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextFieldRecordButton extends StatelessWidget {
|
||||
const _TextFieldRecordButton();
|
||||
const _TextFieldRecordButton({
|
||||
required this.conversationController,
|
||||
required this.keyboardController,
|
||||
});
|
||||
|
||||
final BidirectionalConversationController conversationController;
|
||||
final KeyboardReplacerController keyboardController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -50,15 +72,15 @@ class _TextFieldRecordButton extends StatelessWidget {
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
|
||||
conversationController.startAudioMessageRecording();
|
||||
keyboardController.hideWidget();
|
||||
dismissSoftKeyboard(context);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
|
||||
conversationController.endAudioMessageRecording();
|
||||
},
|
||||
childWhenDragging: const SizedBox(),
|
||||
feedback: SizedBox(
|
||||
@@ -88,26 +110,37 @@ class _TextFieldRecordButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationBottomRow extends StatefulWidget {
|
||||
const ConversationBottomRow(
|
||||
this.tabController,
|
||||
this.focusNode,
|
||||
this.conversationController,
|
||||
this.speedDialValueNotifier, {
|
||||
class ConversationInput extends StatefulWidget {
|
||||
const ConversationInput({
|
||||
required this.keyboardController,
|
||||
required this.conversationController,
|
||||
required this.tabController,
|
||||
required this.speedDialValueNotifier,
|
||||
required this.isEncrypted,
|
||||
required this.textfieldFocusNode,
|
||||
super.key,
|
||||
});
|
||||
final TabController tabController;
|
||||
final FocusNode focusNode;
|
||||
final ValueNotifier<bool> speedDialValueNotifier;
|
||||
|
||||
final KeyboardReplacerController keyboardController;
|
||||
|
||||
final BidirectionalConversationController conversationController;
|
||||
|
||||
final TabController tabController;
|
||||
|
||||
final ValueNotifier<bool> speedDialValueNotifier;
|
||||
|
||||
final bool isEncrypted;
|
||||
|
||||
final FocusNode textfieldFocusNode;
|
||||
|
||||
@override
|
||||
ConversationBottomRowState createState() => ConversationBottomRowState();
|
||||
ConversationInputState createState() => ConversationInputState();
|
||||
}
|
||||
|
||||
class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
class ConversationInputState extends State<ConversationInput> {
|
||||
IconData _getSendButtonIcon(SendButtonState state) {
|
||||
switch (state) {
|
||||
case SendButtonState.hidden:
|
||||
case SendButtonState.multi:
|
||||
return Icons.add;
|
||||
case SendButtonState.send:
|
||||
@@ -117,40 +150,23 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getPickerIcon() {
|
||||
if (widget.tabController.index == 0) {
|
||||
return Icons.insert_emoticon;
|
||||
}
|
||||
|
||||
return PhosphorIcons.stickerBold;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
child: ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
return ColoredBox(
|
||||
color: Colors.black45,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) =>
|
||||
prev.isRecording != next.isRecording,
|
||||
builder: (context, state) => Row(
|
||||
child: Stack(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StreamBuilder<TextFieldData>(
|
||||
initialData: const TextFieldData(
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
),
|
||||
stream: widget
|
||||
.conversationController.textFieldDataStream,
|
||||
stream: widget.conversationController.textFieldDataStream,
|
||||
builder: (context, snapshot) {
|
||||
return CustomTextField(
|
||||
backgroundColor: Theme.of(context)
|
||||
@@ -168,8 +184,8 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
contentPadding: textfieldPaddingConversation,
|
||||
fontSize: textFieldFontSizeConversation,
|
||||
cornerRadius: textfieldRadiusConversation,
|
||||
controller: widget
|
||||
.conversationController.textController,
|
||||
controller:
|
||||
widget.conversationController.textController,
|
||||
topWidget: snapshot.data!.quotedMessage != null
|
||||
? buildQuoteMessageWidget(
|
||||
snapshot.data!.quotedMessage!,
|
||||
@@ -177,29 +193,20 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
snapshot.data!.quotedMessage!,
|
||||
GetIt.I.get<UIDataService>().ownJid!,
|
||||
),
|
||||
resetQuote: widget
|
||||
.conversationController.removeQuote,
|
||||
textfieldQuotedMessageRadius,
|
||||
textfieldQuotedMessageRadius,
|
||||
resetQuote:
|
||||
widget.conversationController.removeQuote,
|
||||
)
|
||||
: null,
|
||||
focusNode: widget.focusNode,
|
||||
shouldSummonKeyboard: () =>
|
||||
!snapshot.data!.pickerVisible,
|
||||
prefixIcon: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
focusNode: widget.textfieldFocusNode,
|
||||
//shouldSummonKeyboard: () => !snapshot.data!.pickerVisible,
|
||||
prefixIcon: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _TextFieldIconButton(
|
||||
snapshot.data!.pickerVisible
|
||||
? Icons.keyboard
|
||||
: _getPickerIcon(),
|
||||
() {
|
||||
widget.conversationController
|
||||
.togglePickerVisibility(true);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
keyboardController: widget.keyboardController,
|
||||
tabController: widget.tabController,
|
||||
textfieldFocusNode: widget.textfieldFocusNode,
|
||||
),
|
||||
),
|
||||
prefixIconConstraints: const BoxConstraints(
|
||||
@@ -208,15 +215,12 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
suffixIcon: snapshot.data!.isBodyEmpty &&
|
||||
snapshot.data!.quotedMessage == null
|
||||
? IntrinsicWidth(
|
||||
child: Row(
|
||||
children: const [
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.only(right: 8),
|
||||
child: _TextFieldRecordButton(),
|
||||
),
|
||||
],
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _TextFieldRecordButton(
|
||||
conversationController:
|
||||
widget.conversationController,
|
||||
keyboardController: widget.keyboardController,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -229,21 +233,20 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: AnimatedOpacity(
|
||||
opacity: state.isRecording ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: IgnorePointer(
|
||||
ignoring: state.isRecording,
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
height: 45,
|
||||
child: StreamBuilder<SendButtonState>(
|
||||
initialData: defaultSendButtonState,
|
||||
stream: widget
|
||||
.conversationController.sendButtonStream,
|
||||
builder: (context, snapshot) {
|
||||
return SpeedDial(
|
||||
stream: widget.conversationController.sendButtonStream,
|
||||
builder: (context, snapshot) => IgnorePointer(
|
||||
ignoring: snapshot.data! == SendButtonState.hidden,
|
||||
child: AnimatedOpacity(
|
||||
opacity:
|
||||
snapshot.data! == SendButtonState.hidden ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: SpeedDial(
|
||||
icon: _getSendButtonIcon(snapshot.data!),
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
@@ -251,23 +254,18 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.image),
|
||||
onTap: () {
|
||||
context
|
||||
.read<ConversationBloc>()
|
||||
.add(
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label:
|
||||
t.pages.conversation.sendImages,
|
||||
label: t.pages.conversation.sendImages,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.file_present),
|
||||
onTap: () {
|
||||
context
|
||||
.read<ConversationBloc>()
|
||||
.add(
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
@@ -285,12 +283,10 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label:
|
||||
t.pages.conversation.takePhotos,
|
||||
label: t.pages.conversation.takePhotos,
|
||||
),
|
||||
],
|
||||
openCloseDial:
|
||||
widget.speedDialValueNotifier,
|
||||
openCloseDial: widget.speedDialValueNotifier,
|
||||
onPress: () {
|
||||
switch (snapshot.data!) {
|
||||
case SendButtonState.cancelCorrection:
|
||||
@@ -298,21 +294,19 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
.endMessageEditing();
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
widget.conversationController
|
||||
.sendMessage(
|
||||
state.conversation!.encrypted,
|
||||
widget.conversationController.sendMessage(
|
||||
widget.isEncrypted,
|
||||
);
|
||||
return;
|
||||
case SendButtonState.multi:
|
||||
widget.speedDialValueNotifier
|
||||
.value =
|
||||
!widget.speedDialValueNotifier
|
||||
.value;
|
||||
widget.speedDialValueNotifier.value =
|
||||
!widget.speedDialValueNotifier.value;
|
||||
return;
|
||||
case SendButtonState.hidden:
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -320,111 +314,48 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBuilder<bool>(
|
||||
initialData: false,
|
||||
stream: widget.conversationController.pickerVisibleStream,
|
||||
builder: (context, snapshot) => Offstage(
|
||||
offstage: !snapshot.data!,
|
||||
child: CombinedPicker(
|
||||
tabController: widget.tabController,
|
||||
onEmojiTapped: (emoji) {
|
||||
final selection = widget
|
||||
.conversationController.textController.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = widget.conversationController.messageBody
|
||||
.substring(0, baseOffset);
|
||||
final suffix = widget.conversationController.messageBody
|
||||
.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue =
|
||||
baseOffset + emoji.emoji.codeUnits.length;
|
||||
widget.conversationController.textController
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspaceTapped: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final text = widget.conversationController.messageBody;
|
||||
final selection = widget
|
||||
.conversationController.textController.selection;
|
||||
final cursorPosition = widget.conversationController
|
||||
.textController.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text)
|
||||
.characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
widget.conversationController.textController
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
onStickerTapped: (sticker, pack) {
|
||||
widget.conversationController.sendSticker(
|
||||
pack.id,
|
||||
sticker.hashKey,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 61,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
|
||||
builder: (context, state) {
|
||||
return AnimatedOpacity(
|
||||
opacity: state.isRecording ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: IgnorePointer(
|
||||
ignoring: !state.isRecording,
|
||||
child: SizedBox(
|
||||
height: textFieldFontSizeConversation + 2 * 12 + 2,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 45 + 16,
|
||||
child: StreamBuilder<RecordingData>(
|
||||
initialData: const RecordingData(
|
||||
false,
|
||||
false,
|
||||
),
|
||||
stream:
|
||||
widget.conversationController.recordingAudioMessageStream,
|
||||
builder: (context, snapshot) => IgnorePointer(
|
||||
ignoring: !snapshot.data!.isRecording,
|
||||
child: AnimatedOpacity(
|
||||
opacity: snapshot.data!.isRecording ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
borderRadius:
|
||||
BorderRadius.circular(textfieldRadiusConversation),
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
// NOTE: We use a comprehension here so that the widget gets
|
||||
// created and destroyed to prevent the timer from running
|
||||
// until the user closes the page.
|
||||
child: state.isRecording
|
||||
? const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: snapshot.data!.isRecording
|
||||
? const TimerWidget()
|
||||
: const SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
250
lib/ui/pages/conversation/keyboard_dodging.dart
Normal file
250
lib/ui/pages/conversation/keyboard_dodging.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:keyboard_height_plugin/keyboard_height_plugin.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
|
||||
/// A triple of data for the child widget wrapper widget.
|
||||
class KeyboardReplacerData {
|
||||
const KeyboardReplacerData(
|
||||
this.visible,
|
||||
this.height,
|
||||
this.showWidget,
|
||||
);
|
||||
|
||||
/// Flag indicating whether the keyboard is visible or not.
|
||||
final bool visible;
|
||||
|
||||
/// The height of the keyboard.
|
||||
final double height;
|
||||
|
||||
/// Flag indicating whether the widget should be shown or not.
|
||||
final bool showWidget;
|
||||
}
|
||||
|
||||
/// A controller to interact with the child wrapper widget.
|
||||
class KeyboardReplacerController {
|
||||
KeyboardReplacerController() {
|
||||
_keyboardVisible = _keyboardController.isVisible;
|
||||
_keyboardVisibilitySubscription =
|
||||
_keyboardController.onChange.listen((visible) {
|
||||
// Only update when the state actually changed
|
||||
if (visible == _keyboardVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
_widgetVisible = false;
|
||||
}
|
||||
|
||||
_keyboardVisible = visible;
|
||||
_streamController.add(
|
||||
KeyboardReplacerData(
|
||||
visible,
|
||||
_keyboardHeight,
|
||||
_widgetVisible,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
_keyboardHeightPlugin.onKeyboardHeightChanged((height) {
|
||||
// Only update when the height actually changed
|
||||
if (height == 0 || height == _keyboardHeight) return;
|
||||
|
||||
_keyboardHeight = height;
|
||||
_streamController.add(
|
||||
KeyboardReplacerData(
|
||||
_keyboardVisible,
|
||||
height,
|
||||
_widgetVisible,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// State of the child widget's visibility.
|
||||
bool _widgetVisible = false;
|
||||
|
||||
/// Data for keeping track of the keyboard visibility.
|
||||
final KeyboardVisibilityController _keyboardController =
|
||||
KeyboardVisibilityController();
|
||||
late final StreamSubscription<bool> _keyboardVisibilitySubscription;
|
||||
|
||||
/// Flag indicating whether the keyboard is currently visible or not.
|
||||
late bool _keyboardVisible;
|
||||
|
||||
/// Data for keeping track of the keyboard height.
|
||||
final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin();
|
||||
|
||||
/// The currently tracked keyboard height.
|
||||
/// NOTE: The value is a random keyboard height I got on my test device.
|
||||
// TODO(Unknown): Maybe make this platform specific.
|
||||
double _keyboardHeight = 260;
|
||||
|
||||
/// The stream for building the child widget wrapper.
|
||||
final StreamController<KeyboardReplacerData> _streamController =
|
||||
StreamController<KeyboardReplacerData>.broadcast();
|
||||
Stream<KeyboardReplacerData> get stream => _streamController.stream;
|
||||
|
||||
/// Get the currently tracked data.
|
||||
KeyboardReplacerData get currentData => KeyboardReplacerData(
|
||||
_keyboardVisible,
|
||||
_keyboardHeight,
|
||||
_widgetVisible,
|
||||
);
|
||||
|
||||
void dispose() {
|
||||
_keyboardVisibilitySubscription.cancel();
|
||||
_keyboardHeightPlugin.dispose();
|
||||
}
|
||||
|
||||
/// Show the child widget in the child wrapper. If the soft-keyboard is currently
|
||||
/// visible, dismiss it.
|
||||
void showWidget(BuildContext context) {
|
||||
_widgetVisible = true;
|
||||
if (_keyboardVisible) {
|
||||
dismissSoftKeyboard(context);
|
||||
}
|
||||
|
||||
// Notify the child widget wrapper
|
||||
_streamController.add(
|
||||
KeyboardReplacerData(
|
||||
false,
|
||||
_keyboardHeight,
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hide the child widget. If [summonKeyboard] is true, then the soft-keyboard is
|
||||
/// summoned.
|
||||
void hideWidget({bool summonKeyboard = false}) {
|
||||
_widgetVisible = false;
|
||||
_streamController.add(
|
||||
KeyboardReplacerData(
|
||||
summonKeyboard,
|
||||
_keyboardHeight,
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggle between (Widget visible, keyboard hidden) and (Widget hidden, keyboard shown).
|
||||
/// Requires the FocusNode [focusNode] of the Textfield.
|
||||
void toggleWidget(BuildContext context, FocusNode focusNode) {
|
||||
if (_widgetVisible) {
|
||||
hideWidget(summonKeyboard: true);
|
||||
focusNode.requestFocus();
|
||||
} else {
|
||||
showWidget(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget for wrapping a given child that should be switching places with the
|
||||
/// soft-keyboard.
|
||||
class KeyboardReplacerWidget extends StatelessWidget {
|
||||
const KeyboardReplacerWidget(this.controller, this.child, {super.key});
|
||||
|
||||
/// A controller that feeds this widget with data.
|
||||
final KeyboardReplacerController controller;
|
||||
|
||||
/// The child to show or not show.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<KeyboardReplacerData>(
|
||||
initialData: controller.currentData,
|
||||
stream: controller.stream,
|
||||
builder: (context, snapshot) {
|
||||
return SizedBox(
|
||||
height: snapshot.data!.visible || snapshot.data!.showWidget
|
||||
? snapshot.data!.height
|
||||
: 0,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Offstage(
|
||||
offstage: !snapshot.data!.showWidget,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative to the regular Scaffold that allows keyboard dodging with a widget
|
||||
/// that switches places with the soft-keyboard.
|
||||
class KeyboardReplacerScaffold extends StatelessWidget {
|
||||
const KeyboardReplacerScaffold({
|
||||
required this.controller,
|
||||
required this.children,
|
||||
required this.appbar,
|
||||
required this.keyboardWidget,
|
||||
required this.background,
|
||||
required this.extraStackChildren,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The KeyboardReplacerController for the keyboard "dodging".
|
||||
final KeyboardReplacerController controller;
|
||||
|
||||
/// The body of the scaffold.
|
||||
final List<Widget> children;
|
||||
|
||||
/// The widget that can switch places with the soft-keyboard.
|
||||
final Widget keyboardWidget;
|
||||
|
||||
/// The app bar.
|
||||
final Widget appbar;
|
||||
|
||||
/// The background of the "scaffold". Useful for displaying a background image.
|
||||
final Widget background;
|
||||
|
||||
final List<Widget>? extraStackChildren;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mq = MediaQuery.of(context);
|
||||
final headerHeight = mq.viewPadding.top;
|
||||
return Stack(
|
||||
children: [
|
||||
// The background should not move when we dodge the keyboard
|
||||
Positioned(
|
||||
// Do not leak under the system UI
|
||||
top: headerHeight,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: background,
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
appbar,
|
||||
...children,
|
||||
KeyboardReplacerWidget(
|
||||
controller,
|
||||
keyboardWidget,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (extraStackChildren != null) ...extraStackChildren!,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
337
lib/ui/pages/conversation/selected_message.dart
Normal file
337
lib/ui/pages/conversation/selected_message.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||
import 'package:moxxyv2/ui/widgets/context_menu.dart';
|
||||
|
||||
/// A data "packet" to describe the selected message.
|
||||
class SelectedMessageData {
|
||||
const SelectedMessageData(
|
||||
this.message,
|
||||
this.isChatEncrypted,
|
||||
this.sentBySelf,
|
||||
this.originalPosition,
|
||||
this.requiredYOffset,
|
||||
this.start,
|
||||
this.between,
|
||||
this.end,
|
||||
);
|
||||
|
||||
/// The message content.
|
||||
final Message? message;
|
||||
|
||||
/// Flag indicating whether the current chat is encrypted or not.
|
||||
final bool isChatEncrypted;
|
||||
|
||||
/// Flag indicating whether [message] was sent by ourselves or not.
|
||||
final bool sentBySelf;
|
||||
|
||||
/// The original screen position of the message.
|
||||
final Offset originalPosition;
|
||||
|
||||
/// The offset that is required for the animation to work.
|
||||
final double requiredYOffset;
|
||||
|
||||
/// Flags for the message's corners.
|
||||
final bool start;
|
||||
final bool between;
|
||||
final bool end;
|
||||
}
|
||||
|
||||
/// A controller class for managing a [SelectedMessage] widget.
|
||||
class SelectedMessageController {
|
||||
SelectedMessageController(this._controller, this.animation) {
|
||||
_controller.addListener(_onAnimationChanged);
|
||||
}
|
||||
|
||||
/// Provide the stream to the widget.
|
||||
final StreamController<SelectedMessageData> _streamController =
|
||||
StreamController<SelectedMessageData>.broadcast();
|
||||
Stream<SelectedMessageData> get stream => _streamController.stream;
|
||||
|
||||
/// The [AnimationController] and the animation. Used for detecting the end of the
|
||||
/// animation and providing data to the widget.
|
||||
final AnimationController _controller;
|
||||
final Animation<double> animation;
|
||||
|
||||
/// Flag indicating whether we should reset [state] when the [AnimationController]'s
|
||||
/// value hits 0.
|
||||
bool _shouldClearMessage = false;
|
||||
|
||||
/// The current state of what the widget is displaying.
|
||||
SelectedMessageData state = const SelectedMessageData(
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
Offset.zero,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
void _onAnimationChanged() {
|
||||
if (_controller.value == 0 && _shouldClearMessage) {
|
||||
// Prevent the widget from constantly being rendered and layouted.
|
||||
_streamController.add(
|
||||
const SelectedMessageData(
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
Offset.zero,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
);
|
||||
_shouldClearMessage = false;
|
||||
} else if (_controller.value == 1) {
|
||||
_shouldClearMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a message as selected and start the animation.
|
||||
void selectMessage(SelectedMessageData data) {
|
||||
state = data;
|
||||
_streamController.add(data);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
/// Dismiss the message selection
|
||||
void dismiss() {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
class SelectedMessage extends StatelessWidget {
|
||||
const SelectedMessage(this.controller, {super.key});
|
||||
|
||||
/// The controller for managing the state and the animation.
|
||||
final SelectedMessageController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<SelectedMessageData>(
|
||||
initialData: controller.state,
|
||||
stream: controller.stream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data!.message == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller.animation,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
left: snapshot.data!.originalPosition.dx,
|
||||
top: snapshot.data!.originalPosition.dy +
|
||||
controller.animation.value * snapshot.data!.requiredYOffset,
|
||||
child: IgnorePointer(
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: RawChatBubble(
|
||||
snapshot.data!.message!,
|
||||
MediaQuery.of(context).size.width * 0.6,
|
||||
snapshot.data!.sentBySelf,
|
||||
snapshot.data!.isChatEncrypted,
|
||||
snapshot.data!.start,
|
||||
snapshot.data!.between,
|
||||
snapshot.data!.end,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectedMessageContextMenu extends StatelessWidget {
|
||||
const SelectedMessageContextMenu({
|
||||
required this.selectionController,
|
||||
required this.conversationController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final SelectedMessageController selectionController;
|
||||
|
||||
final BidirectionalConversationController conversationController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<SelectedMessageData>(
|
||||
stream: selectionController.stream,
|
||||
initialData: selectionController.state,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data!.message == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final sentBySelf = snapshot.data!.sentBySelf;
|
||||
final message = snapshot.data!.message!;
|
||||
return AnimatedBuilder(
|
||||
animation: selectionController.animation,
|
||||
builder: (context, _) => Positioned(
|
||||
left: sentBySelf ? null : 20,
|
||||
right: sentBySelf ? 20 : null,
|
||||
bottom: 20,
|
||||
child: Opacity(
|
||||
opacity: selectionController.animation.value,
|
||||
child: ContextMenu(
|
||||
children: [
|
||||
if (message.isReactable)
|
||||
ContextMenuItem(
|
||||
icon: Icons.add_reaction,
|
||||
text: t.pages.conversation.addReaction,
|
||||
onPressed: () async {
|
||||
final emoji = await pickEmoji(context, pop: false);
|
||||
if (emoji != null) {
|
||||
await MoxplatformPlugin.handler
|
||||
.getDataSender()
|
||||
.sendData(
|
||||
AddReactionToMessageCommand(
|
||||
messageId: message.id,
|
||||
emoji: emoji,
|
||||
conversationJid:
|
||||
conversationController.conversationJid,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.canRetract(sentBySelf))
|
||||
ContextMenuItem(
|
||||
icon: Icons.delete,
|
||||
text: t.pages.conversation.retract,
|
||||
onPressed: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.conversation.retract,
|
||||
t.pages.conversation.retractBody,
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
conversationController
|
||||
.retractMessage(message.originId!);
|
||||
}
|
||||
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
// TODO(Unknown): Also allow correcting older messages
|
||||
if (message.canEdit(sentBySelf) &&
|
||||
GetIt.I
|
||||
.get<ConversationBloc>()
|
||||
.state
|
||||
.conversation
|
||||
?.lastMessage
|
||||
?.id ==
|
||||
message.id)
|
||||
ContextMenuItem(
|
||||
icon: Icons.edit,
|
||||
text: t.pages.conversation.edit,
|
||||
onPressed: () {
|
||||
conversationController.beginMessageEditing(
|
||||
message.body,
|
||||
message.quotes,
|
||||
message.id,
|
||||
message.sid,
|
||||
);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.errorMenuVisible)
|
||||
ContextMenuItem(
|
||||
icon: Icons.info_outline,
|
||||
text: t.pages.conversation.showError,
|
||||
onPressed: () {
|
||||
showInfoDialog(
|
||||
t.errors.conversation.messageErrorDialogTitle,
|
||||
errorToTranslatableString(
|
||||
message.errorType!,
|
||||
),
|
||||
context,
|
||||
);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.hasWarning)
|
||||
ContextMenuItem(
|
||||
icon: Icons.warning,
|
||||
text: t.pages.conversation.showWarning,
|
||||
onPressed: () {
|
||||
showInfoDialog(
|
||||
'Warning',
|
||||
warningToTranslatableString(
|
||||
message.warningType!,
|
||||
),
|
||||
context,
|
||||
);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.isCopyable)
|
||||
ContextMenuItem(
|
||||
icon: Icons.content_copy,
|
||||
text: t.pages.conversation.copy,
|
||||
onPressed: () {
|
||||
// TODO(Unknown): Show a toast saying the message has been copied
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: message.body,
|
||||
),
|
||||
);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.isQuotable && message.conversationJid != '')
|
||||
ContextMenuItem(
|
||||
icon: Icons.forward,
|
||||
text: t.pages.conversation.forward,
|
||||
onPressed: () {
|
||||
showNotImplementedDialog(
|
||||
'sharing',
|
||||
context,
|
||||
);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.isQuotable)
|
||||
ContextMenuItem(
|
||||
icon: Icons.reply,
|
||||
text: t.pages.conversation.quote,
|
||||
onPressed: () {
|
||||
conversationController.quoteMessage(message);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
|
||||
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
|
||||
@@ -18,12 +18,12 @@ enum ConversationOption { close, block }
|
||||
|
||||
enum EncryptionOption { omemo, none }
|
||||
|
||||
PopupMenuItem<dynamic> popupItemWithIcon(
|
||||
dynamic value,
|
||||
PopupMenuItem<T> popupItemWithIcon<T>(
|
||||
T value,
|
||||
String text,
|
||||
IconData icon,
|
||||
) {
|
||||
return PopupMenuItem<dynamic>(
|
||||
return PopupMenuItem<T>(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -39,26 +39,25 @@ PopupMenuItem<dynamic> popupItemWithIcon(
|
||||
|
||||
/// A custom version of the BorderlessTopbar to display the conversation topbar
|
||||
/// as it should
|
||||
// TODO(PapaTutuWawa): The conversation title may overflow the Topbar
|
||||
// TODO(Unknown): Maybe merge with BorderlessTopbar
|
||||
class ConversationTopbar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ConversationTopbar({super.key});
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(60);
|
||||
Size get preferredSize =>
|
||||
const Size.fromHeight(BorderlessTopbar.topbarPreferredHeight);
|
||||
|
||||
bool _shouldRebuild(ConversationState prev, ConversationState next) {
|
||||
return prev.conversation?.title != next.conversation?.title ||
|
||||
prev.conversation?.avatarUrl != next.conversation?.avatarUrl ||
|
||||
prev.conversation?.chatState != next.conversation?.chatState ||
|
||||
prev.conversation?.jid != next.conversation?.jid ||
|
||||
prev.conversation?.encrypted != next.conversation?.encrypted ||
|
||||
prev.conversation?.sharedMedia != next.conversation?.sharedMedia;
|
||||
prev.conversation?.encrypted != next.conversation?.encrypted;
|
||||
}
|
||||
|
||||
Widget _buildChatState(ChatState state) {
|
||||
switch (state) {
|
||||
case ChatState.composing:
|
||||
case ChatState.paused:
|
||||
case ChatState.active:
|
||||
return Text(
|
||||
@@ -67,12 +66,9 @@ class ConversationTopbar extends StatelessWidget
|
||||
color: Colors.green,
|
||||
),
|
||||
);
|
||||
case ChatState.composing:
|
||||
// TODO(Unknown): Colors
|
||||
return const TypingIndicatorWidget(Colors.black, Colors.white);
|
||||
case ChatState.inactive:
|
||||
case ChatState.gone:
|
||||
return Container();
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,20 +92,20 @@ class ConversationTopbar extends StatelessWidget
|
||||
buildWhen: _shouldRebuild,
|
||||
builder: (context, state) {
|
||||
final chatState = state.conversation?.chatState ?? ChatState.gone;
|
||||
return SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Flex(
|
||||
direction: Axis.horizontal,
|
||||
return BorderlessTopbar(
|
||||
children: [
|
||||
const BackButton(),
|
||||
InkWell(
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _openProfile(context, state),
|
||||
child: Hero(
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'conversation_profile_picture',
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
@@ -126,38 +122,28 @@ class ConversationTopbar extends StatelessWidget
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _openProfile(context, state),
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
top: _isChatStateVisible(chatState) ? 0 : 10,
|
||||
left: 0,
|
||||
left: 60,
|
||||
right: 0,
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
RebuildOnContactIntegrationChange(
|
||||
builder: () => TopbarTitleText(
|
||||
state.conversation
|
||||
?.titleWithOptionalContact ??
|
||||
'',
|
||||
child: RebuildOnContactIntegrationChange(
|
||||
builder: () => Text(
|
||||
state.conversation?.titleWithOptionalContact ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: fontsizeAppbar,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
left: 25,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedOpacity(
|
||||
opacity:
|
||||
_isChatStateVisible(chatState) ? 1.0 : 0.0,
|
||||
opacity: _isChatStateVisible(chatState) ? 1.0 : 0.0,
|
||||
curve: Curves.easeInOutCubic,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Row(
|
||||
@@ -172,40 +158,35 @@ class ConversationTopbar extends StatelessWidget
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.conversation?.type != ConversationType.note)
|
||||
// ignore: implicit_dynamic_type
|
||||
PopupMenuButton(
|
||||
PopupMenuButton<EncryptionOption>(
|
||||
onSelected: (result) {
|
||||
if (result == EncryptionOption.omemo &&
|
||||
state.conversation!.encrypted == false) {
|
||||
context
|
||||
.read<ConversationBloc>()
|
||||
.add(OmemoSetEvent(true));
|
||||
context.read<ConversationBloc>().add(OmemoSetEvent(true));
|
||||
} else if (result == EncryptionOption.none &&
|
||||
state.conversation!.encrypted == true) {
|
||||
context
|
||||
.read<ConversationBloc>()
|
||||
.add(OmemoSetEvent(false));
|
||||
context.read<ConversationBloc>().add(OmemoSetEvent(false));
|
||||
}
|
||||
},
|
||||
icon: (state.conversation?.encrypted ?? false)
|
||||
? const Icon(Icons.lock)
|
||||
: const Icon(Icons.lock_open),
|
||||
itemBuilder: (BuildContext c) => [
|
||||
popupItemWithIcon(
|
||||
popupItemWithIcon<EncryptionOption>(
|
||||
EncryptionOption.none,
|
||||
t.pages.conversation.unencrypted,
|
||||
Icons.lock_open,
|
||||
),
|
||||
popupItemWithIcon(
|
||||
popupItemWithIcon<EncryptionOption>(
|
||||
EncryptionOption.omemo,
|
||||
t.pages.conversation.encrypted,
|
||||
Icons.lock,
|
||||
),
|
||||
],
|
||||
),
|
||||
// ignore: implicit_dynamic_type
|
||||
PopupMenuButton(
|
||||
PopupMenuButton<ConversationOption>(
|
||||
onSelected: (result) async {
|
||||
switch (result) {
|
||||
case ConversationOption.close:
|
||||
@@ -241,13 +222,13 @@ class ConversationTopbar extends StatelessWidget
|
||||
},
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (BuildContext c) => [
|
||||
popupItemWithIcon(
|
||||
popupItemWithIcon<ConversationOption>(
|
||||
ConversationOption.close,
|
||||
t.pages.conversation.closeChat,
|
||||
Icons.close,
|
||||
),
|
||||
if (state.conversation?.type != ConversationType.note)
|
||||
popupItemWithIcon(
|
||||
popupItemWithIcon<ConversationOption>(
|
||||
ConversationOption.block,
|
||||
t.pages.conversation.blockUser,
|
||||
Icons.block,
|
||||
@@ -255,10 +236,6 @@ class ConversationTopbar extends StatelessWidget
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
108
lib/ui/pages/conversation/typing_indicator.dart
Normal file
108
lib/ui/pages/conversation/typing_indicator.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
|
||||
|
||||
/// A helper widget that displays a [TypingIndicatorWidget] in a stack, so that it can
|
||||
/// be animated in and out of the bottom of the screen with a "sliding" animation.
|
||||
class AnimatedTypingIndicator extends StatefulWidget {
|
||||
const AnimatedTypingIndicator({
|
||||
required this.visible,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// If set to true, animate the typing indicator into view. If false, reverse the
|
||||
/// animation.
|
||||
final bool visible;
|
||||
|
||||
@override
|
||||
AnimatedTypingIndicatorState createState() => AnimatedTypingIndicatorState();
|
||||
}
|
||||
|
||||
class AnimatedTypingIndicatorState extends State<AnimatedTypingIndicator>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = Tween<double>(
|
||||
begin: 0,
|
||||
end: 1,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOutCubic,
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.visible) {
|
||||
_controller.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedTypingIndicator oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (widget.visible != oldWidget.visible) {
|
||||
if (widget.visible) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
if (_animation.value == 0) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Pad the stack to the desired height
|
||||
SizedBox(
|
||||
height: 40 * _animation.value,
|
||||
),
|
||||
|
||||
child!,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: const Positioned(
|
||||
top: 0,
|
||||
left: 8,
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColorReceived,
|
||||
borderRadius: BorderRadius.all(radiusLarge),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: TypingIndicatorWidget(Colors.black, Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||
import 'package:moxxyv2/ui/widgets/context_menu.dart';
|
||||
import 'package:moxxyv2/ui/widgets/conversation.dart';
|
||||
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
|
||||
enum ConversationsOptions { settings }
|
||||
@@ -91,38 +91,77 @@ class ConversationsPage extends StatefulWidget {
|
||||
|
||||
class ConversationsPageState extends State<ConversationsPage>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late Animation<double> _convY;
|
||||
/// The JID of the currently selected conversation.
|
||||
Conversation? _selectedConversation;
|
||||
|
||||
/// Data for the context menu animation
|
||||
late final AnimationController _contextMenuController;
|
||||
late final Animation<double> _contextMenuAnimation;
|
||||
final Map<String, GlobalKey> _conversationKeys = {};
|
||||
|
||||
/// The required offset from the top of the stack for the context menu.
|
||||
double _topStackOffset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
||||
_contextMenuController = AnimationController(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
_contextMenuAnimation = Tween<double>(
|
||||
begin: 0,
|
||||
end: 1,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _contextMenuController,
|
||||
curve: Curves.easeInOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_contextMenuController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _listWrapper(BuildContext context, ConversationsState state) {
|
||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
||||
void dismissContextMenu() {
|
||||
_contextMenuController.reverse();
|
||||
setState(() {
|
||||
_selectedConversation = null;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _listWrapper(BuildContext context, ConversationsState state) {
|
||||
if (state.conversations.isNotEmpty) {
|
||||
return ListView.builder(
|
||||
itemCount: state.conversations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.conversations[index];
|
||||
|
||||
GlobalKey key;
|
||||
if (_conversationKeys.containsKey(item.jid)) {
|
||||
key = _conversationKeys[item.jid]!;
|
||||
} else {
|
||||
key = GlobalKey();
|
||||
_conversationKeys[item.jid] = key;
|
||||
}
|
||||
|
||||
final row = ConversationsListRow(
|
||||
maxTextWidth,
|
||||
item,
|
||||
true,
|
||||
enableAvatarOnTap: true,
|
||||
key: ValueKey('conversationRow;${item.jid}'),
|
||||
isSelected: _selectedConversation?.jid == item.jid,
|
||||
onPressed: () => GetIt.I.get<ConversationBloc>().add(
|
||||
RequestedConversationEvent(
|
||||
item.jid,
|
||||
item.title,
|
||||
item.avatarUrl,
|
||||
),
|
||||
),
|
||||
key: key,
|
||||
);
|
||||
|
||||
return ConversationsRowDismissible(
|
||||
@@ -131,80 +170,41 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
onLongPressStart: (event) async {
|
||||
Vibrate.feedback(FeedbackType.medium);
|
||||
|
||||
_convY = Tween<double>(
|
||||
begin: event.globalPosition.dy - 20,
|
||||
end: 200,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOutCubic,
|
||||
),
|
||||
);
|
||||
final widgetRect = getWidgetPositionOnScreen(key);
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
|
||||
await _controller.forward();
|
||||
setState(() {
|
||||
_selectedConversation = item;
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => OverviewMenu(
|
||||
_convY,
|
||||
highlight: row,
|
||||
left: 0,
|
||||
right: 0,
|
||||
children: [
|
||||
if (item.unreadCounter != 0)
|
||||
OverviewMenuItem(
|
||||
icon: Icons.done_all,
|
||||
text: t.pages.conversations.markAsRead,
|
||||
onPressed: () {
|
||||
context.read<ConversationsBloc>().add(
|
||||
ConversationMarkedAsReadEvent(item.jid),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
OverviewMenuItem(
|
||||
icon: Icons.close,
|
||||
text: t.pages.conversations.closeChat,
|
||||
onPressed: () async {
|
||||
// ignore: use_build_context_synchronously
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.conversations.closeChat,
|
||||
t.pages.conversations.closeChatBody(
|
||||
conversationTitle: item.title,
|
||||
),
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
// TODO(Unknown): Show a snackbar allowing the user to revert the action
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<ConversationsBloc>().add(
|
||||
ConversationClosedEvent(item.jid),
|
||||
);
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
final numberOptions = item.numberContextMenuOptions;
|
||||
if (height - widgetRect.bottom >
|
||||
40 + numberOptions * ContextMenuItem.height) {
|
||||
// In this case, we have enough space below the conversation item,
|
||||
// so we say that the top of the context menu is
|
||||
// widgetRect.bottom (Bottom y coordinate of the conversation item)
|
||||
// minus 20 (padding so we're not directly against the conversation
|
||||
// item) - the height of the top bar.
|
||||
_topStackOffset = widgetRect.bottom -
|
||||
20 -
|
||||
BorderlessTopbar.topbarPreferredHeight;
|
||||
} else {
|
||||
// In this case we don't have sufficient space below the conversation
|
||||
// item, so we place the context menu above it.
|
||||
// The computation is the same as in the above branch, but now
|
||||
// we position the context menu above and thus also substract the
|
||||
// height of the context menu
|
||||
// (numberOptions * ContextMenuItem.height).
|
||||
_topStackOffset = widgetRect.top -
|
||||
20 -
|
||||
numberOptions * ContextMenuItem.height -
|
||||
BorderlessTopbar.topbarPreferredHeight;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await _controller.reverse();
|
||||
await _contextMenuController.forward();
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () => GetIt.I.get<ConversationBloc>().add(
|
||||
RequestedConversationEvent(
|
||||
item.jid,
|
||||
item.title,
|
||||
item.avatarUrl,
|
||||
),
|
||||
),
|
||||
child: row,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -236,9 +236,31 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ConversationsBloc, ConversationsState>(
|
||||
builder: (BuildContext context, ConversationsState state) => Scaffold(
|
||||
appBar: BorderlessTopbar.avatarAndName(
|
||||
TopbarAvatarAndName(
|
||||
TopbarTitleText(state.displayName),
|
||||
appBar: BorderlessTopbar(
|
||||
showBackButton: false,
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Dismiss the selection, if we have an active one
|
||||
if (_selectedConversation != null) {
|
||||
dismissContextMenu();
|
||||
}
|
||||
|
||||
GetIt.I.get<profile.ProfileBloc>().add(
|
||||
profile.ProfilePageRequestedEvent(
|
||||
true,
|
||||
jid: state.jid,
|
||||
avatarUrl: state.avatarUrl,
|
||||
displayName: state.displayName,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'self_profile_picture',
|
||||
child: Material(
|
||||
@@ -250,17 +272,23 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
() => GetIt.I.get<profile.ProfileBloc>().add(
|
||||
profile.ProfilePageRequestedEvent(
|
||||
true,
|
||||
jid: state.jid,
|
||||
avatarUrl: state.avatarUrl,
|
||||
displayName: state.displayName,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
state.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: fontsizeAppbar,
|
||||
),
|
||||
),
|
||||
showBackButton: false,
|
||||
extra: [
|
||||
PopupMenuButton(
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: PopupMenuButton(
|
||||
onSelected: (ConversationsOptions result) {
|
||||
switch (result) {
|
||||
case ConversationsOptions.settings:
|
||||
@@ -275,11 +303,86 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
child: Text(t.pages.conversations.overlaySettings),
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
_listWrapper(context, state),
|
||||
if (_selectedConversation != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
onTap: dismissContextMenu,
|
||||
// NOTE: We must set the color to Colors.transparent because the container
|
||||
// would otherwise not span the entire screen (or Scaffold body to be
|
||||
// more precise).
|
||||
child: const ColoredBox(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: _topStackOffset,
|
||||
left: 8,
|
||||
child: AnimatedBuilder(
|
||||
animation: _contextMenuAnimation,
|
||||
builder: (context, child) => IgnorePointer(
|
||||
ignoring: _selectedConversation == null,
|
||||
child: Opacity(
|
||||
opacity: _contextMenuAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: ContextMenu(
|
||||
children: [
|
||||
if ((_selectedConversation?.unreadCounter ?? 0) > 0)
|
||||
ContextMenuItem(
|
||||
icon: Icons.done_all,
|
||||
text: t.pages.conversations.markAsRead,
|
||||
onPressed: () {
|
||||
context.read<ConversationsBloc>().add(
|
||||
ConversationMarkedAsReadEvent(
|
||||
_selectedConversation!.jid,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ContextMenuItem(
|
||||
icon: Icons.close,
|
||||
text: t.pages.conversations.closeChat,
|
||||
onPressed: () async {
|
||||
// ignore: use_build_context_synchronously
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.conversations.closeChat,
|
||||
t.pages.conversations.closeChatBody(
|
||||
conversationTitle:
|
||||
_selectedConversation?.title ?? '',
|
||||
),
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
// TODO(Unknown): Show a snackbar allowing the user to revert the action
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<ConversationsBloc>().add(
|
||||
ConversationClosedEvent(
|
||||
_selectedConversation!.jid,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _listWrapper(context, state),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: SpeedDial(
|
||||
icon: Icons.chat,
|
||||
curve: Curves.bounceInOut,
|
||||
|
||||
@@ -23,7 +23,7 @@ class Login extends StatelessWidget {
|
||||
builder: (BuildContext context, LoginState state) => WillPopScope(
|
||||
onWillPop: () async => !state.working,
|
||||
child: Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.login.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.login.title),
|
||||
body: Column(
|
||||
children: [
|
||||
Visibility(
|
||||
|
||||
@@ -20,8 +20,13 @@ class NewConversationPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _renderIconEntry(IconData icon, String text, void Function() onTap) {
|
||||
return InkWell(
|
||||
Widget _renderIconEntry(IconData icon, String text, VoidCallback onTap) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radiusLargeSize),
|
||||
child: Material(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -44,14 +49,16 @@ class NewConversationPage extends StatelessWidget {
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.newconversation.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.newconversation.title),
|
||||
body: BlocBuilder<NewConversationBloc, NewConversationState>(
|
||||
builder: (BuildContext context, NewConversationState state) =>
|
||||
ListView.builder(
|
||||
@@ -87,17 +94,7 @@ class NewConversationPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.read<NewConversationBloc>().add(
|
||||
NewConversationAddedEvent(
|
||||
item.jid,
|
||||
item.title,
|
||||
item.avatarUrl,
|
||||
ConversationType.chat,
|
||||
),
|
||||
),
|
||||
child: ConversationsListRow(
|
||||
maxTextWidth,
|
||||
Conversation(
|
||||
item.title,
|
||||
Message(
|
||||
@@ -110,31 +107,36 @@ class NewConversationPage extends StatelessWidget {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
item.avatarUrl,
|
||||
item.jid,
|
||||
0,
|
||||
ConversationType.chat,
|
||||
0,
|
||||
[],
|
||||
true,
|
||||
true,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
ChatState.gone,
|
||||
0,
|
||||
contactId: item.contactId,
|
||||
contactAvatarPath: item.contactAvatarPath,
|
||||
contactDisplayName: item.contactDisplayName,
|
||||
),
|
||||
false,
|
||||
showTimestamp: false,
|
||||
isSelected: false,
|
||||
onPressed: () => context.read<NewConversationBloc>().add(
|
||||
NewConversationAddedEvent(
|
||||
item.jid,
|
||||
item.title,
|
||||
item.avatarUrl,
|
||||
ConversationType.chat,
|
||||
),
|
||||
),
|
||||
titleSuffixIcon:
|
||||
item.pseudoRosterItem ? Icons.smartphone : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,8 +11,7 @@ import 'package:moxxyv2/ui/widgets/profile/options.dart';
|
||||
//import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
class ConversationProfileHeader extends StatelessWidget {
|
||||
const ConversationProfileHeader(this.conversation, {super.key});
|
||||
final Conversation conversation;
|
||||
const ConversationProfileHeader({super.key});
|
||||
|
||||
Future<void> _showAvatarFullsize(BuildContext context, String path) async {
|
||||
await showDialog<void>(
|
||||
@@ -25,7 +24,7 @@ class ConversationProfileHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context) {
|
||||
Widget _buildAvatar(BuildContext context, Conversation conversation) {
|
||||
return RebuildOnContactIntegrationChange(
|
||||
builder: () {
|
||||
final path = conversation.avatarPathWithOptionalContact;
|
||||
@@ -49,6 +48,9 @@ class ConversationProfileHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ProfileBloc, ProfileState>(
|
||||
builder: (context, state) {
|
||||
final conversation = state.conversation!;
|
||||
return Column(
|
||||
children: [
|
||||
Hero(
|
||||
@@ -56,6 +58,7 @@ class ConversationProfileHeader extends StatelessWidget {
|
||||
child: Material(
|
||||
child: _buildAvatar(
|
||||
context,
|
||||
conversation,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -121,5 +124,7 @@ class ConversationProfileHeader extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +100,9 @@ class DevicesPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DevicesBloc, DevicesState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: BorderlessTopbar.simple(
|
||||
appBar: BorderlessTopbar.title(
|
||||
t.pages.profile.devices.title,
|
||||
extra: [
|
||||
const Spacer(),
|
||||
PopupMenuButton(
|
||||
trailing: PopupMenuButton(
|
||||
onSelected: (DevicesOptions result) {
|
||||
if (result == DevicesOptions.recreateSessions) {
|
||||
_recreateSessions(context);
|
||||
@@ -119,7 +117,6 @@ class DevicesPage extends StatelessWidget {
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(context, state),
|
||||
),
|
||||
|
||||
@@ -180,11 +180,9 @@ class OwnDevicesPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<OwnDevicesBloc, OwnDevicesState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: BorderlessTopbar.simple(
|
||||
appBar: BorderlessTopbar.title(
|
||||
t.pages.profile.owndevices.title,
|
||||
extra: [
|
||||
const Spacer(),
|
||||
PopupMenuButton(
|
||||
trailing: PopupMenuButton(
|
||||
onSelected: (OwnDevicesOptions result) {
|
||||
switch (result) {
|
||||
case OwnDevicesOptions.recreateSessions:
|
||||
@@ -208,7 +206,6 @@ class OwnDevicesPage extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(context, state),
|
||||
),
|
||||
|
||||
@@ -1,91 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/conversationheader.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/selfheader.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
|
||||
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/profile_view.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/shared_media_view.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
const ProfilePage({super.key});
|
||||
class ProfileArguments {
|
||||
ProfileArguments(this.isSelfProfile, this.jid);
|
||||
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
builder: (_) => const ProfilePage(),
|
||||
bool isSelfProfile;
|
||||
|
||||
/// The JID of the conversation entity.
|
||||
String jid;
|
||||
}
|
||||
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage(this.arguments, {super.key});
|
||||
|
||||
/// The arguments passed to the page
|
||||
final ProfileArguments arguments;
|
||||
|
||||
static MaterialPageRoute<dynamic> getRoute(ProfileArguments arguments) =>
|
||||
MaterialPageRoute<dynamic>(
|
||||
builder: (_) => ProfilePage(arguments),
|
||||
settings: const RouteSettings(
|
||||
name: profileRoute,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildHeader(BuildContext context, ProfileState state) {
|
||||
if (state.isSelfProfile) {
|
||||
return SelfProfileHeader(
|
||||
state.jid,
|
||||
state.avatarUrl,
|
||||
state.displayName,
|
||||
(path, hash) => context.read<ProfileBloc>().add(
|
||||
AvatarSetEvent(path, hash),
|
||||
),
|
||||
@override
|
||||
ProfilePageState createState() => ProfilePageState();
|
||||
}
|
||||
|
||||
class ProfilePageState extends State<ProfilePage> {
|
||||
late final PageController _pageController;
|
||||
|
||||
int _pageIndex = 0;
|
||||
|
||||
late final BidirectionalSharedMediaController _mediaController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_pageController = PageController()..addListener(_onPageControllerUpdate);
|
||||
_mediaController = BidirectionalSharedMediaController(
|
||||
widget.arguments.jid,
|
||||
);
|
||||
}
|
||||
|
||||
return ConversationProfileHeader(state.conversation!);
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_mediaController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPageControllerUpdate() {
|
||||
if (_pageController.hasClients) {
|
||||
final page = _pageController.page!.round();
|
||||
if (page != _pageIndex) {
|
||||
setState(() {
|
||||
_pageIndex = page;
|
||||
});
|
||||
} else if (_pageController.page! >= 0.5 &&
|
||||
!_mediaController.hasFetchedOnce) {
|
||||
_mediaController.fetchOlderData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
body: BlocBuilder<ProfileBloc, ProfileState>(
|
||||
builder: (context, state) => Stack(
|
||||
alignment: Alignment.center,
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _buildHeader(context, state),
|
||||
Scaffold(
|
||||
bottomNavigationBar: !widget.arguments.isSelfProfile
|
||||
? BottomNavigationBar(
|
||||
currentIndex: _pageIndex,
|
||||
onTap: (index) {
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOutQuint,
|
||||
);
|
||||
setState(() {
|
||||
_pageIndex = index;
|
||||
});
|
||||
},
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.person),
|
||||
label: t.pages.profile.general.profile,
|
||||
),
|
||||
if (!state.isSelfProfile &&
|
||||
state.conversation!.sharedMedia.isNotEmpty)
|
||||
SharedMediaDisplay(
|
||||
preview: state.conversation!.sharedMedia,
|
||||
jid: state.conversation!.jid,
|
||||
title: state.conversation!.titleWithOptionalContact,
|
||||
sharedMediaAmount: state.conversation!.sharedMediaAmount,
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.perm_media),
|
||||
label: t.pages.profile.general.media,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
physics: widget.arguments.isSelfProfile
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
children: [
|
||||
ProfileView(
|
||||
widget.arguments,
|
||||
),
|
||||
SharedMediaView(
|
||||
_mediaController,
|
||||
key: const PageStorageKey('shared_media_view'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () =>
|
||||
context.read<NavigationBloc>().add(PoppedRouteEvent()),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Visibility(
|
||||
visible: state.isSelfProfile,
|
||||
child: IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
context
|
||||
.read<ServerInfoBloc>()
|
||||
.add(ServerInfoPageRequested());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
47
lib/ui/pages/profile/profile_view.dart
Normal file
47
lib/ui/pages/profile/profile_view.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/conversationheader.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/profile.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/selfheader.dart';
|
||||
|
||||
class ProfileView extends StatelessWidget {
|
||||
const ProfileView(this.arguments, {super.key});
|
||||
|
||||
final ProfileArguments arguments;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: arguments.isSelfProfile
|
||||
? SelfProfileHeader(arguments)
|
||||
: const ConversationProfileHeader(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Visibility(
|
||||
visible: arguments.isSelfProfile,
|
||||
child: IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
context.read<ServerInfoBloc>().add(ServerInfoPageRequested());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,32 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/profile.dart';
|
||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||
import 'package:moxxyv2/ui/widgets/profile/options.dart';
|
||||
|
||||
class SelfProfileHeader extends StatelessWidget {
|
||||
const SelfProfileHeader(
|
||||
this.jid,
|
||||
this.avatarUrl,
|
||||
this.displayName,
|
||||
this.setAvatar, {
|
||||
super.key,
|
||||
});
|
||||
final String jid;
|
||||
final String avatarUrl;
|
||||
final String displayName;
|
||||
final void Function(String, String) setAvatar;
|
||||
const SelfProfileHeader(this.arguments, {super.key});
|
||||
|
||||
Future<void> pickAndSetAvatar(BuildContext context) async {
|
||||
final avatar = await pickAvatar(context, jid, avatarUrl);
|
||||
final ProfileArguments arguments;
|
||||
|
||||
Future<void> pickAndSetAvatar(BuildContext context, String avatarUrl) async {
|
||||
final avatar = await pickAvatar(context, arguments.jid, avatarUrl);
|
||||
|
||||
if (avatar != null) {
|
||||
setAvatar(avatar.path, avatar.hash);
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<ProfileBloc>().add(
|
||||
AvatarSetEvent(avatar.path, avatar.hash),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ProfileBloc, ProfileState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
Hero(
|
||||
@@ -36,9 +35,10 @@ class SelfProfileHeader extends StatelessWidget {
|
||||
child: Material(
|
||||
child: AvatarWrapper(
|
||||
radius: 110,
|
||||
avatarUrl: avatarUrl,
|
||||
avatarUrl: state.avatarUrl,
|
||||
altIcon: Icons.person,
|
||||
onTapFunction: () => pickAndSetAvatar(context),
|
||||
onTapFunction: () =>
|
||||
pickAndSetAvatar(context, state.avatarUrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -48,7 +48,7 @@ class SelfProfileHeader extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
state.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
),
|
||||
@@ -62,7 +62,7 @@ class SelfProfileHeader extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
jid,
|
||||
arguments.jid,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
),
|
||||
@@ -71,7 +71,8 @@ class SelfProfileHeader extends StatelessWidget {
|
||||
padding: const EdgeInsetsDirectional.only(start: 3),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.qr_code),
|
||||
onPressed: () => showQrCode(context, 'xmpp:$jid'),
|
||||
onPressed: () =>
|
||||
showQrCode(context, 'xmpp:${arguments.jid}'),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -102,5 +103,7 @@ class SelfProfileHeader extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
97
lib/ui/pages/profile/shared_media_view.dart
Normal file
97
lib/ui/pages/profile/shared_media_view.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/grouped_grid_view.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
|
||||
class SharedMediaView extends StatelessWidget {
|
||||
const SharedMediaView(this.mediaController, {super.key});
|
||||
|
||||
/// The controller used for requesting shared media messages.
|
||||
final BidirectionalSharedMediaController mediaController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
return Scaffold(
|
||||
appBar: const BorderlessTopbar(
|
||||
showBackButton: false,
|
||||
// Ensure the top bar has a height
|
||||
children: [
|
||||
SizedBox(
|
||||
height: BorderlessTopbar.topbarPreferredHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: StreamBuilder<bool>(
|
||||
stream: mediaController.isFetchingStream,
|
||||
initialData: mediaController.isFetching,
|
||||
builder: (context, snapshot) {
|
||||
return snapshot.data!
|
||||
? const LinearProgressIndicator()
|
||||
: const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: StreamBuilder<List<Message>>(
|
||||
stream: mediaController.dataStream,
|
||||
initialData: mediaController.cache,
|
||||
builder: (context, snapshot) {
|
||||
return GroupedGridView<Message, DateTime>(
|
||||
controller: mediaController.scrollController,
|
||||
elements: snapshot.data!,
|
||||
getKey: (m) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(m.timestamp);
|
||||
return DateTime(
|
||||
dt.year,
|
||||
dt.month,
|
||||
dt.day,
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
gridPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemBuilder: (_, message) => buildSharedMediaWidget(
|
||||
message.fileMetadata!,
|
||||
message.conversationJid,
|
||||
),
|
||||
separatorBuilder: (_, timestamp) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 16,
|
||||
),
|
||||
child: Text(
|
||||
formatDateBubble(timestamp, now),
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ class ServerInfoPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple('Server Information'),
|
||||
// TODO(PapaTutuWawa): Translate
|
||||
appBar: BorderlessTopbar.title('Server Information'),
|
||||
body: BlocBuilder<ServerInfoBloc, ServerInfoState>(
|
||||
builder: (BuildContext context, ServerInfoState state) {
|
||||
if (state.working) {
|
||||
|
||||
@@ -43,7 +43,7 @@ class SettingsAboutPageState extends State<SettingsAboutPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.about.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.about.title),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||
child: Column(
|
||||
|
||||
@@ -50,7 +50,7 @@ class AppearanceSettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.appearance.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.appearance.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
|
||||
@@ -69,7 +69,7 @@ class ConversationSettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.conversation.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.conversation.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
|
||||
@@ -28,7 +28,7 @@ class DebuggingPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.debugging.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.debugging.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
|
||||
@@ -52,7 +52,7 @@ class SettingsLicensesPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.licenses.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.licenses.title),
|
||||
body: ListView.builder(
|
||||
itemCount: usedLibraryList.length,
|
||||
itemBuilder: (context, index) =>
|
||||
|
||||
@@ -106,7 +106,7 @@ class NetworkPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.network.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.network.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
|
||||
@@ -22,7 +22,7 @@ class PrivacyPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.privacy.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.privacy.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
|
||||
@@ -26,7 +26,7 @@ class SettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.settings.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.settings.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||
builder: (context, state) => ListView(
|
||||
|
||||
@@ -37,8 +37,7 @@ class StickersSettingsPage extends StatelessWidget {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Scaffold(
|
||||
appBar:
|
||||
BorderlessTopbar.simple(t.pages.settings.stickers.title),
|
||||
appBar: BorderlessTopbar.title(t.pages.settings.stickers.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (_, prefs) => Padding(
|
||||
padding: EdgeInsets.zero,
|
||||
|
||||
@@ -46,8 +46,6 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
GetIt.I.get<ShareSelectionBloc>().add(ResetEvent());
|
||||
@@ -67,21 +65,13 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>(
|
||||
buildWhen: _buildWhen,
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.shareselection.shareWith),
|
||||
appBar: BorderlessTopbar.title(t.pages.shareselection.shareWith),
|
||||
body: ListView.builder(
|
||||
itemCount: state.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
final isSelected = state.selection.contains(index);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
context.read<ShareSelectionBloc>().add(
|
||||
SelectionToggledEvent(index),
|
||||
);
|
||||
},
|
||||
child: ConversationsListRow(
|
||||
maxTextWidth,
|
||||
return ConversationsListRow(
|
||||
Conversation(
|
||||
item.title,
|
||||
null,
|
||||
@@ -90,14 +80,12 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
0,
|
||||
ConversationType.chat,
|
||||
0,
|
||||
[],
|
||||
true,
|
||||
true,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
ChatState.gone,
|
||||
0,
|
||||
contactId: item.contactId,
|
||||
contactAvatarPath: item.contactAvatarPath,
|
||||
contactDisplayName: item.contactDisplayName,
|
||||
@@ -105,16 +93,12 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
false,
|
||||
titleSuffixIcon: _getSuffixIcon(item),
|
||||
showTimestamp: false,
|
||||
extraWidgetWidth: 48,
|
||||
extra: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) {
|
||||
isSelected: state.selection.contains(index),
|
||||
onPressed: () {
|
||||
context.read<ShareSelectionBloc>().add(
|
||||
SelectionToggledEvent(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/grouped_grid_view.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
|
||||
class SharedMediaPageArguments {
|
||||
SharedMediaPageArguments(
|
||||
this.conversationJid,
|
||||
this.conversationTitle,
|
||||
);
|
||||
|
||||
/// The JID of the conversation we display the shared media items of.
|
||||
final String conversationJid;
|
||||
|
||||
/// The title of the conversation.
|
||||
final String conversationTitle;
|
||||
}
|
||||
|
||||
class SharedMediaPage extends StatefulWidget {
|
||||
const SharedMediaPage({
|
||||
required this.arguments,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static MaterialPageRoute<void> getRoute(SharedMediaPageArguments arguments) {
|
||||
return MaterialPageRoute<void>(
|
||||
builder: (_) => SharedMediaPage(arguments: arguments),
|
||||
settings: const RouteSettings(
|
||||
name: sharedMediaRoute,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The arguments passed to the page.
|
||||
final SharedMediaPageArguments arguments;
|
||||
|
||||
@override
|
||||
SharedMediaPageState createState() => SharedMediaPageState();
|
||||
}
|
||||
|
||||
class SharedMediaPageState extends State<SharedMediaPage> {
|
||||
late final BidirectionalSharedMediaController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller =
|
||||
BidirectionalSharedMediaController(widget.arguments.conversationJid);
|
||||
_controller.fetchOlderData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(widget.arguments.conversationTitle),
|
||||
body: StreamBuilder<List<SharedMedium>>(
|
||||
initialData: const [],
|
||||
stream: _controller.dataStream,
|
||||
builder: (context, snapshot) {
|
||||
return GroupedGridView<SharedMedium, DateTime>(
|
||||
controller: _controller.scrollController,
|
||||
elements: snapshot.data!,
|
||||
getKey: (m) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(m.timestamp);
|
||||
return DateTime(
|
||||
dt.year,
|
||||
dt.month,
|
||||
dt.day,
|
||||
);
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
gridPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemBuilder: (_, medium) => buildSharedMediaWidget(
|
||||
medium,
|
||||
widget.arguments.conversationJid,
|
||||
),
|
||||
separatorBuilder: (_, timestamp) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 16,
|
||||
),
|
||||
child: Text(
|
||||
formatDateBubble(timestamp, now),
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,14 @@ class StickerWrapper extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (sticker.path.isNotEmpty) {
|
||||
if (sticker.fileMetadata.path != null) {
|
||||
return Image.file(
|
||||
File(sticker.path),
|
||||
File(sticker.fileMetadata.path!),
|
||||
fit: cover ? BoxFit.contain : null,
|
||||
);
|
||||
} else {
|
||||
return Image.network(
|
||||
sticker.urlSources.first,
|
||||
sticker.fileMetadata.sourceUrls!.first,
|
||||
fit: cover ? BoxFit.contain : null,
|
||||
loadingBuilder: (_, child, event) {
|
||||
if (event == null) return child;
|
||||
@@ -214,9 +214,7 @@ class StickerPackPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickerPackBloc, StickerPackState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
appBar: BorderlessTopbar.simple(
|
||||
state.stickerPack?.name ?? '...',
|
||||
),
|
||||
appBar: BorderlessTopbar.title(state.stickerPack?.name ?? '...'),
|
||||
body: state.isWorking
|
||||
? SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
|
||||
@@ -100,8 +100,8 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Text(
|
||||
widget.message.isMedia && widget.message.mediaSize != null
|
||||
? '${fileSizeToString(widget.message.mediaSize!)} • $_timestampString'
|
||||
widget.message.isMedia && widget.message.fileMetadata!.size != null
|
||||
? '${fileSizeToString(widget.message.fileMetadata!.size!)} • $_timestampString'
|
||||
: _timestampString,
|
||||
style: const TextStyle(
|
||||
fontSize: fontsizeSubbody,
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/reactionbubble.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/reactions/preview.dart';
|
||||
import 'package:swipeable_tile/swipeable_tile.dart';
|
||||
|
||||
class RawChatBubble extends StatelessWidget {
|
||||
@@ -53,8 +52,9 @@ class RawChatBubble extends StatelessWidget {
|
||||
/// Specified when the message bubble should not have color
|
||||
bool _shouldNotColorBubble() {
|
||||
var isInlinedWidget = false;
|
||||
if (message.mediaType != null) {
|
||||
isInlinedWidget = message.mediaType!.startsWith('image/');
|
||||
if (message.isMedia) {
|
||||
isInlinedWidget =
|
||||
message.fileMetadata!.mimeType?.startsWith('image/') ?? false;
|
||||
}
|
||||
|
||||
// Check if it is a pseudo message
|
||||
@@ -63,12 +63,14 @@ class RawChatBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
// Check if it is an embedded file
|
||||
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) {
|
||||
if (message.isMedia &&
|
||||
message.fileMetadata!.path != null &&
|
||||
isInlinedWidget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stickers are also not colored
|
||||
return message.stickerPackId != null && message.stickerHashKey != null;
|
||||
return message.stickerPackId != null;
|
||||
}
|
||||
|
||||
Color _getBubbleColor(BuildContext context) {
|
||||
@@ -114,6 +116,8 @@ class RawChatBubble extends StatelessWidget {
|
||||
maxWidth,
|
||||
borderRadius,
|
||||
sentBySelf,
|
||||
borderRadius.topLeft.x,
|
||||
borderRadius.topRight.x,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -129,7 +133,6 @@ class ChatBubble extends StatefulWidget {
|
||||
required this.onSwipedCallback,
|
||||
required this.bubble,
|
||||
this.onLongPressed,
|
||||
this.onReactionTap,
|
||||
super.key,
|
||||
});
|
||||
final Message message;
|
||||
@@ -142,8 +145,6 @@ class ChatBubble extends StatefulWidget {
|
||||
final GestureLongPressStartCallback? onLongPressed;
|
||||
// The actual message bubble
|
||||
final RawChatBubble bubble;
|
||||
// For acting on reaction taps
|
||||
final void Function(Reaction)? onReactionTap;
|
||||
|
||||
@override
|
||||
ChatBubbleState createState() => ChatBubbleState();
|
||||
@@ -165,33 +166,6 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
: SwipeDirection.startToEnd;
|
||||
}
|
||||
|
||||
Widget _buildReactions() {
|
||||
if (widget.message.reactions.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: Wrap(
|
||||
spacing: 1,
|
||||
runSpacing: 2,
|
||||
children: widget.message.reactions
|
||||
.map(
|
||||
(reaction) => ReactionBubble(
|
||||
emoji: reaction.emoji,
|
||||
reactions: reaction.reactions,
|
||||
reactedTo: reaction.reactedBySelf,
|
||||
sentBySelf: widget.sentBySelf,
|
||||
onTap: widget.onReactionTap != null
|
||||
? () => widget.onReactionTap!(reaction)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -267,7 +241,15 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
child: Align(
|
||||
alignment:
|
||||
widget.sentBySelf ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
right: widget.sentBySelf ? 0 : null,
|
||||
left: widget.sentBySelf ? null : 0,
|
||||
child: ReactionsPreview(widget.message, widget.sentBySelf),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: widget.sentBySelf
|
||||
? CrossAxisAlignment.end
|
||||
@@ -277,7 +259,18 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
onLongPressStart: widget.onLongPressed,
|
||||
child: widget.bubble,
|
||||
),
|
||||
_buildReactions(),
|
||||
if (widget.message.reactionsPreview.isNotEmpty)
|
||||
// This SizedBox ensures that we have a proper bottom padding for the
|
||||
// reaction preview, but also ensure that the Stack is wide enough
|
||||
// so that the preview is not clipped by the Stack, since the overflow
|
||||
// does not receive input events.
|
||||
// See https://github.com/flutter/flutter/issues/19445
|
||||
SizedBox(
|
||||
height: 40,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
@@ -8,8 +7,8 @@ import 'package:moxxyv2/shared/models/message.dart';
|
||||
/// Calculate the transformed size of a media message based on its stored
|
||||
/// dimensions.
|
||||
Size getMediaSize(Message message, double maxWidth) {
|
||||
final mediaWidth = message.mediaWidth?.toDouble();
|
||||
final mediaHeight = message.mediaHeight?.toDouble();
|
||||
final mediaWidth = message.fileMetadata?.width?.toDouble();
|
||||
final mediaHeight = message.fileMetadata?.height?.toDouble();
|
||||
|
||||
var width = maxWidth;
|
||||
var height = maxWidth;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message/audio.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
||||
@@ -37,7 +38,7 @@ MessageType getMessageType(Message message) {
|
||||
return MessageType.sticker;
|
||||
}
|
||||
|
||||
final mime = message.mediaType;
|
||||
final mime = message.fileMetadata!.mimeType;
|
||||
if (mime == null) return MessageType.file;
|
||||
|
||||
if (mime.startsWith('image/')) {
|
||||
@@ -60,6 +61,8 @@ Widget buildMessageWidget(
|
||||
double maxWidth,
|
||||
BorderRadius radius,
|
||||
bool sent,
|
||||
double topLeftRadius,
|
||||
double topRightRadius,
|
||||
) {
|
||||
// Retracted messages are always rendered as a text message
|
||||
if (message.isRetracted) {
|
||||
@@ -67,7 +70,12 @@ Widget buildMessageWidget(
|
||||
message,
|
||||
sent,
|
||||
topWidget: message.quotes != null
|
||||
? buildQuoteMessageWidget(message.quotes!, sent)
|
||||
? buildQuoteMessageWidget(
|
||||
message.quotes!,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -79,7 +87,12 @@ Widget buildMessageWidget(
|
||||
message,
|
||||
sent,
|
||||
topWidget: message.quotes != null
|
||||
? buildQuoteMessageWidget(message.quotes!, sent)
|
||||
? buildQuoteMessageWidget(
|
||||
message.quotes!,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -88,7 +101,19 @@ Widget buildMessageWidget(
|
||||
case MessageType.video:
|
||||
return VideoChatWidget(message, radius, maxWidth, sent);
|
||||
case MessageType.sticker:
|
||||
return StickerChatWidget(message, radius, maxWidth, sent);
|
||||
return StickerChatWidget(
|
||||
message,
|
||||
maxWidth,
|
||||
sent,
|
||||
quotedMessage: message.quotes != null
|
||||
? buildQuoteMessageWidget(
|
||||
message.quotes!,
|
||||
sent,
|
||||
radiusLargeSize,
|
||||
radiusLargeSize,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
case MessageType.audio:
|
||||
return AudioChatWidget(message, radius, maxWidth, sent);
|
||||
case MessageType.file:
|
||||
@@ -99,48 +124,86 @@ Widget buildMessageWidget(
|
||||
/// Build a widget that represents a quoted message within another bubble.
|
||||
Widget buildQuoteMessageWidget(
|
||||
Message message,
|
||||
bool sent, {
|
||||
bool sent,
|
||||
double topLeftRadius,
|
||||
double topRightRadius, {
|
||||
void Function()? resetQuote,
|
||||
}) {
|
||||
switch (getMessageType(message)) {
|
||||
case MessageType.sticker:
|
||||
return QuotedStickerWidget(message, sent, resetQuote: resetQuote);
|
||||
return QuotedStickerWidget(
|
||||
message,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
resetQuote: resetQuote,
|
||||
);
|
||||
case MessageType.text:
|
||||
return QuotedTextWidget(message, sent, resetQuote: resetQuote);
|
||||
return QuotedTextWidget(
|
||||
message,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
resetQuote: resetQuote,
|
||||
);
|
||||
case MessageType.image:
|
||||
return QuotedImageWidget(message, sent, resetQuote: resetQuote);
|
||||
return QuotedImageWidget(
|
||||
message,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
resetQuote: resetQuote,
|
||||
);
|
||||
case MessageType.video:
|
||||
return QuotedVideoWidget(message, sent, resetQuote: resetQuote);
|
||||
return QuotedVideoWidget(
|
||||
message,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
resetQuote: resetQuote,
|
||||
);
|
||||
case MessageType.audio:
|
||||
return QuotedAudioWidget(message, sent, resetQuote: resetQuote);
|
||||
return QuotedAudioWidget(
|
||||
message,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
resetQuote: resetQuote,
|
||||
);
|
||||
case MessageType.file:
|
||||
return QuotedFileWidget(message, sent, resetQuote: resetQuote);
|
||||
return QuotedFileWidget(
|
||||
message,
|
||||
sent,
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
resetQuote: resetQuote,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildSharedMediaWidget(SharedMedium medium, String conversationJid) {
|
||||
if (medium.mime!.startsWith('image/')) {
|
||||
Widget buildSharedMediaWidget(FileMetadata metadata, String conversationJid) {
|
||||
if (metadata.mimeType!.startsWith('image/')) {
|
||||
return SharedImageWidget(
|
||||
medium.path,
|
||||
onTap: () => openFile(medium.path),
|
||||
metadata.path!,
|
||||
onTap: () => openFile(metadata.path!),
|
||||
);
|
||||
} else if (medium.mime!.startsWith('video/')) {
|
||||
} else if (metadata.mimeType!.startsWith('video/')) {
|
||||
return SharedVideoWidget(
|
||||
medium.path,
|
||||
metadata.path!,
|
||||
conversationJid,
|
||||
medium.mime!,
|
||||
onTap: () => openFile(medium.path),
|
||||
metadata.mimeType!,
|
||||
onTap: () => openFile(metadata.path!),
|
||||
child: const PlayButton(size: 32),
|
||||
);
|
||||
} else if (medium.mime!.startsWith('audio/')) {
|
||||
} else if (metadata.mimeType!.startsWith('audio/')) {
|
||||
return SharedAudioWidget(
|
||||
medium.path,
|
||||
onTap: () => openFile(medium.path),
|
||||
metadata.path!,
|
||||
onTap: () => openFile(metadata.path!),
|
||||
);
|
||||
}
|
||||
|
||||
return SharedFileWidget(
|
||||
medium.path,
|
||||
onTap: () => openFile(medium.path),
|
||||
metadata.path!,
|
||||
onTap: () => openFile(metadata.path!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class AudioChatState extends State<AudioChatWidget> {
|
||||
|
||||
Future<void> _init() async {
|
||||
_audioFile = Audio.loadFromAbsolutePath(
|
||||
widget.message.mediaUrl!,
|
||||
widget.message.fileMetadata!.path!,
|
||||
onDuration: (double seconds) {
|
||||
setState(() {
|
||||
_duration = seconds;
|
||||
@@ -251,11 +251,11 @@ class AudioChatState extends State<AudioChatWidget> {
|
||||
Widget _buildDownloadable() {
|
||||
return FileChatBaseWidget(
|
||||
widget.message,
|
||||
widget.message.filename!,
|
||||
widget.message.fileMetadata!.filename,
|
||||
widget.radius,
|
||||
widget.maxWidth,
|
||||
widget.sent,
|
||||
mimeType: widget.message.mediaType,
|
||||
mimeType: widget.message.fileMetadata!.mimeType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
@@ -276,8 +276,8 @@ class AudioChatState extends State<AudioChatWidget> {
|
||||
}
|
||||
|
||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||
if (widget.message.mediaUrl != null &&
|
||||
File(widget.message.mediaUrl!).existsSync()) {
|
||||
if (widget.message.fileMetadata!.path != null &&
|
||||
File(widget.message.fileMetadata!.path!).existsSync()) {
|
||||
return _buildAudio();
|
||||
}
|
||||
|
||||
|
||||
@@ -123,11 +123,11 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildNonDownloaded() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
message.filename!,
|
||||
message.fileMetadata!.filename,
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
mimeType: message.fileMetadata!.mimeType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
@@ -142,11 +142,11 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildDownloading() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
message.filename!,
|
||||
message.fileMetadata!.filename,
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
mimeType: message.fileMetadata!.filename,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
@@ -154,20 +154,20 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildInner() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
message.filename!,
|
||||
message.fileMetadata!.filename,
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
mimeType: message.fileMetadata!.mimeType,
|
||||
onTap: () {
|
||||
openFile(message.mediaUrl!);
|
||||
openFile(message.fileMetadata!.path!);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!message.isDownloading && message.mediaUrl != null) {
|
||||
if (!message.isDownloading && message.fileMetadata!.path != null) {
|
||||
return _buildInner();
|
||||
}
|
||||
if (message.isFileUploadNotification || message.isDownloading) {
|
||||
|
||||
@@ -27,7 +27,7 @@ class ImageChatWidget extends StatelessWidget {
|
||||
|
||||
Widget _buildUploading() {
|
||||
return MediaBaseChatWidget(
|
||||
Image.file(File(message.mediaUrl!)),
|
||||
Image.file(File(message.fileMetadata!.path!)),
|
||||
MessageBubbleBottom(message, sent),
|
||||
radius,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
@@ -35,7 +35,7 @@ class ImageChatWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildDownloading() {
|
||||
if (message.thumbnailData != null) {
|
||||
if (message.fileMetadata!.thumbnailData != null) {
|
||||
final size = getMediaSize(message, maxWidth);
|
||||
|
||||
return MediaBaseChatWidget(
|
||||
@@ -43,7 +43,7 @@ class ImageChatWidget extends StatelessWidget {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: BlurHash(
|
||||
hash: message.thumbnailData!,
|
||||
hash: message.fileMetadata!.thumbnailData!,
|
||||
decodingWidth: size.width.toInt(),
|
||||
decodingHeight: size.height.toInt(),
|
||||
),
|
||||
@@ -55,11 +55,11 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
message.filename!,
|
||||
message.fileMetadata!.filename,
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
mimeType: message.fileMetadata!.mimeType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
@@ -70,21 +70,23 @@ class ImageChatWidget extends StatelessWidget {
|
||||
final size = getMediaSize(message, maxWidth);
|
||||
|
||||
Widget image;
|
||||
if (message.mediaWidth != null && message.mediaHeight != null) {
|
||||
if (message.fileMetadata!.width != null &&
|
||||
message.fileMetadata!.height != null) {
|
||||
image = SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Image.file(
|
||||
File(message.mediaUrl!),
|
||||
File(message.fileMetadata!.path!),
|
||||
cacheWidth: size.width.toInt(),
|
||||
cacheHeight: size.height.toInt(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// TODO(Unknown): Somehow have sensible defaults here
|
||||
image = Image.file(
|
||||
File(message.mediaUrl!),
|
||||
cacheWidth: size.width.toInt(),
|
||||
cacheHeight: size.height.toInt(),
|
||||
File(message.fileMetadata!.path!),
|
||||
// cacheWidth: size.width.toInt(),
|
||||
// cacheHeight: size.height.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,12 +97,12 @@ class ImageChatWidget extends StatelessWidget {
|
||||
sent,
|
||||
),
|
||||
radius,
|
||||
onTap: () => openFile(message.mediaUrl!),
|
||||
onTap: () => openFile(message.fileMetadata!.path!),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadable() {
|
||||
if (message.thumbnailData != null) {
|
||||
if (message.fileMetadata!.thumbnailData != null) {
|
||||
final size = getMediaSize(message, maxWidth);
|
||||
|
||||
return MediaBaseChatWidget(
|
||||
@@ -108,7 +110,7 @@ class ImageChatWidget extends StatelessWidget {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: BlurHash(
|
||||
hash: message.thumbnailData!,
|
||||
hash: message.fileMetadata!.thumbnailData!,
|
||||
decodingWidth: size.width.toInt(),
|
||||
decodingHeight: size.height.toInt(),
|
||||
),
|
||||
@@ -122,11 +124,11 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
message.filename!,
|
||||
message.fileMetadata!.filename,
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
mimeType: message.fileMetadata!.mimeType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
@@ -149,7 +151,8 @@ class ImageChatWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||
if (message.mediaUrl != null && File(message.mediaUrl!).existsSync()) {
|
||||
if (message.fileMetadata!.path != null &&
|
||||
File(message.fileMetadata!.path!).existsSync()) {
|
||||
return _buildImage();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user