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
|
.android
|
||||||
|
|
||||||
# Build scripts
|
# Build scripts
|
||||||
release/
|
release-*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.6.10'
|
ext.kotlin_version = '1.8.21'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -194,7 +194,9 @@
|
|||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"general": {
|
"general": {
|
||||||
"omemo": "Security"
|
"omemo": "Security",
|
||||||
|
"profile": "Profile",
|
||||||
|
"media": "Media"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
|
|||||||
@@ -194,7 +194,9 @@
|
|||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"general": {
|
"general": {
|
||||||
"omemo": "Sicherheit"
|
"omemo": "Sicherheit",
|
||||||
|
"profile": "Profil",
|
||||||
|
"media": "Medien"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
|
|||||||
@@ -265,14 +265,14 @@ files:
|
|||||||
messages:
|
messages:
|
||||||
type: List<Message>
|
type: List<Message>
|
||||||
deserialise: true
|
deserialise: true
|
||||||
# Returned by [GetPagedSharedMediaCommand]
|
# Returned by [GetReactionsForMessageCommand]
|
||||||
- name: PagedSharedMediaResultEvent
|
- name: ReactionsForMessageResult
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
media:
|
reactions:
|
||||||
type: List<SharedMedium>
|
type: List<ReactionGroup>
|
||||||
deserialise: true
|
deserialise: true
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
builder_name: "Event"
|
builder_name: "Event"
|
||||||
@@ -527,9 +527,13 @@ files:
|
|||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
stickerPackId: String
|
sticker:
|
||||||
stickerHashKey: String
|
type: Sticker
|
||||||
|
deserialise: true
|
||||||
recipient: String
|
recipient: String
|
||||||
|
quotes:
|
||||||
|
type: Message?
|
||||||
|
deserialise: true
|
||||||
- name: FetchStickerPackCommand
|
- name: FetchStickerPackCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -566,6 +570,12 @@ files:
|
|||||||
conversationJid: String
|
conversationJid: String
|
||||||
olderThan: bool
|
olderThan: bool
|
||||||
timestamp: int?
|
timestamp: int?
|
||||||
|
- name: GetReactionsForMessageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messageId: int
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
# get${builder_Name}FromJson
|
# get${builder_Name}FromJson
|
||||||
builder_name: "Command"
|
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/settings.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/share_selection.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/splashscreen/splashscreen.dart';
|
||||||
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||||
import 'package:moxxyv2/ui/pages/util/qrcode.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,
|
conversationJid: settings.arguments! as String,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case sharedMediaRoute:
|
// case sharedMediaRoute:
|
||||||
return SharedMediaPage.getRoute(
|
// return SharedMediaPage.getRoute(
|
||||||
settings.arguments! as SharedMediaPageArguments,
|
// settings.arguments! as SharedMediaPageArguments,
|
||||||
);
|
// );
|
||||||
case blocklistRoute:
|
case blocklistRoute:
|
||||||
return BlocklistPage.route;
|
return BlocklistPage.route;
|
||||||
case profileRoute:
|
case profileRoute:
|
||||||
return ProfilePage.route;
|
return ProfilePage.getRoute(
|
||||||
|
settings.arguments! as ProfileArguments,
|
||||||
|
);
|
||||||
case settingsRoute:
|
case settingsRoute:
|
||||||
return SettingsPage.route;
|
return SettingsPage.route;
|
||||||
case aboutRoute:
|
case aboutRoute:
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class AvatarService {
|
|||||||
final am = GetIt.I
|
final am = GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
final idResult = await am.getAvatarId(jid);
|
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||||
if (idResult.isType<AvatarError>()) {
|
if (idResult.isType<AvatarError>()) {
|
||||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||||
return null;
|
return null;
|
||||||
@@ -220,7 +220,7 @@ class AvatarService {
|
|||||||
final xss = GetIt.I.get<XmppStateService>();
|
final xss = GetIt.I.get<XmppStateService>();
|
||||||
final state = await xss.getXmppState();
|
final state = await xss.getXmppState();
|
||||||
final jid = state.jid!;
|
final jid = state.jid!;
|
||||||
final idResult = await am.getAvatarId(jid);
|
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||||
if (idResult.isType<AvatarError>()) {
|
if (idResult.isType<AvatarError>()) {
|
||||||
_log.info('Error while getting latest avatar id for own avatar');
|
_log.info('Error while getting latest avatar id for own avatar');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.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/database.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
@@ -15,6 +16,23 @@ class BlocklistService {
|
|||||||
bool? _supported;
|
bool? _supported;
|
||||||
final Logger _log = Logger('BlocklistService');
|
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() {
|
void onNewConnection() {
|
||||||
// Invalidate the caches
|
// Invalidate the caches
|
||||||
_blocklist = null;
|
_blocklist = null;
|
||||||
@@ -49,10 +67,9 @@ class BlocklistService {
|
|||||||
// Diff the received blocklist with the cache
|
// Diff the received blocklist with the cache
|
||||||
final newItems = List<String>.empty(growable: true);
|
final newItems = List<String>.empty(growable: true);
|
||||||
final removedItems = List<String>.empty(growable: true);
|
final removedItems = List<String>.empty(growable: true);
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
for (final item in blocklist) {
|
for (final item in blocklist) {
|
||||||
if (!_blocklist!.contains(item)) {
|
if (!_blocklist!.contains(item)) {
|
||||||
await db.addBlocklistEntry(item);
|
await _addBlocklistEntry(item);
|
||||||
_blocklist!.add(item);
|
_blocklist!.add(item);
|
||||||
newItems.add(item);
|
newItems.add(item);
|
||||||
}
|
}
|
||||||
@@ -61,7 +78,7 @@ class BlocklistService {
|
|||||||
// Diff the cache with the received blocklist
|
// Diff the cache with the received blocklist
|
||||||
for (final item in _blocklist!) {
|
for (final item in _blocklist!) {
|
||||||
if (!blocklist.contains(item)) {
|
if (!blocklist.contains(item)) {
|
||||||
await db.removeBlocklistEntry(item);
|
await _removeBlocklistEntry(item);
|
||||||
_blocklist!.remove(item);
|
_blocklist!.remove(item);
|
||||||
removedItems.add(item);
|
removedItems.add(item);
|
||||||
}
|
}
|
||||||
@@ -83,7 +100,9 @@ class BlocklistService {
|
|||||||
/// Returns the blocklist from the database
|
/// Returns the blocklist from the database
|
||||||
Future<List<String>> getBlocklist() async {
|
Future<List<String>> getBlocklist() async {
|
||||||
if (_blocklist == null) {
|
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) {
|
if (!_requested) {
|
||||||
unawaited(_requestBlocklist());
|
unawaited(_requestBlocklist());
|
||||||
@@ -120,7 +139,7 @@ class BlocklistService {
|
|||||||
_blocklist!.add(item);
|
_blocklist!.add(item);
|
||||||
newBlocks.add(item);
|
newBlocks.add(item);
|
||||||
|
|
||||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
|
await _addBlocklistEntry(item);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case BlockPushType.unblock:
|
case BlockPushType.unblock:
|
||||||
@@ -128,7 +147,7 @@ class BlocklistService {
|
|||||||
_blocklist!.removeWhere((i) => i == item);
|
_blocklist!.removeWhere((i) => i == item);
|
||||||
removedBlocks.add(item);
|
removedBlocks.add(item);
|
||||||
|
|
||||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
|
await _removeBlocklistEntry(item);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -150,7 +169,7 @@ class BlocklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_blocklist!.add(jid);
|
_blocklist!.add(jid);
|
||||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(jid);
|
await _addBlocklistEntry(jid);
|
||||||
return GetIt.I
|
return GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<BlockingManager>(blockingManager)!
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
@@ -165,7 +184,7 @@ class BlocklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_blocklist!.remove(jid);
|
_blocklist!.remove(jid);
|
||||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(jid);
|
await _removeBlocklistEntry(jid);
|
||||||
return GetIt.I
|
return GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<BlockingManager>(blockingManager)!
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
@@ -182,7 +201,8 @@ class BlocklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_blocklist!.clear();
|
_blocklist!.clear();
|
||||||
await GetIt.I.get<DatabaseService>().removeAllBlocklistEntries();
|
await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
|
||||||
|
|
||||||
return GetIt.I
|
return GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<BlockingManager>(blockingManager)!
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxyv2/service/conversation.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/database.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
@@ -122,7 +123,15 @@ class ContactsService {
|
|||||||
Future<Map<String, String>> _getContactIds() async {
|
Future<Map<String, String>> _getContactIds() async {
|
||||||
if (_contactIds != null) return _contactIds!;
|
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!;
|
return _contactIds!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +174,6 @@ class ContactsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> scanContacts() async {
|
Future<void> scanContacts() async {
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final rs = GetIt.I.get<RosterService>();
|
final rs = GetIt.I.get<RosterService>();
|
||||||
final contacts = await _fetchContactsWithJabber();
|
final contacts = await _fetchContactsWithJabber();
|
||||||
@@ -183,7 +191,12 @@ class ContactsService {
|
|||||||
if (index != -1) continue;
|
if (index != -1) continue;
|
||||||
|
|
||||||
final jid = knownContactIdsReverse[id]!;
|
final jid = knownContactIdsReverse[id]!;
|
||||||
await db.removeContactId(id);
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
contactsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
_contactIds!.remove(knownContactIdsReverse[id]);
|
_contactIds!.remove(knownContactIdsReverse[id]);
|
||||||
|
|
||||||
// Remove the avatar file, if it existed
|
// Remove the avatar file, if it existed
|
||||||
@@ -235,7 +248,13 @@ class ContactsService {
|
|||||||
for (final contact in contacts) {
|
for (final contact in contacts) {
|
||||||
// Add the ID to the cache and the database if it does not already exist
|
// Add the ID to the cache and the database if it does not already exist
|
||||||
if (!knownContactIds.containsKey(contact.jid)) {
|
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;
|
_contactIds![contact.jid] = contact.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.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/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/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/preferences.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/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:synchronized/synchronized.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
|
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||||
/// to the cache.
|
/// to the cache.
|
||||||
Future<void> _loadConversationsIfNeeded() async {
|
Future<void> _loadConversationsIfNeeded() async {
|
||||||
if (_conversationCache != null) return;
|
if (_conversationCache != null) return;
|
||||||
|
|
||||||
final conversations =
|
final conversations = await loadConversations();
|
||||||
await GetIt.I.get<DatabaseService>().loadConversations();
|
|
||||||
_conversationCache = Map<String, Conversation>.fromEntries(
|
_conversationCache = Map<String, Conversation>.fromEntries(
|
||||||
conversations.map((c) => MapEntry(c.jid, c)),
|
conversations.map((c) => MapEntry(c.jid, c)),
|
||||||
);
|
);
|
||||||
@@ -87,7 +126,8 @@ class ConversationService {
|
|||||||
_conversationCache![conversation.jid] = conversation;
|
_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
|
/// To prevent issues with the cache, only call from within
|
||||||
/// [ConversationService.createOrUpdateConversation].
|
/// [ConversationService.createOrUpdateConversation].
|
||||||
Future<Conversation> updateConversation(
|
Future<Conversation> updateConversation(
|
||||||
@@ -103,25 +143,58 @@ class ConversationService {
|
|||||||
Object? contactId = notSpecified,
|
Object? contactId = notSpecified,
|
||||||
Object? contactAvatarPath = notSpecified,
|
Object? contactAvatarPath = notSpecified,
|
||||||
Object? contactDisplayName = notSpecified,
|
Object? contactDisplayName = notSpecified,
|
||||||
int? sharedMediaAmount,
|
|
||||||
}) async {
|
}) async {
|
||||||
final conversation = (await _getConversationByJid(jid))!;
|
final conversation = (await _getConversationByJid(jid))!;
|
||||||
var newConversation =
|
|
||||||
await GetIt.I.get<DatabaseService>().updateConversation(
|
final c = <String, dynamic>{};
|
||||||
jid,
|
|
||||||
lastMessage: lastMessage,
|
if (lastMessage != null) {
|
||||||
lastChangeTimestamp: lastChangeTimestamp,
|
c['lastMessageId'] = lastMessage.id;
|
||||||
open: open,
|
}
|
||||||
unreadCounter: unreadCounter,
|
if (lastChangeTimestamp != null) {
|
||||||
avatarUrl: avatarUrl,
|
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
||||||
chatState: conversation.chatState,
|
}
|
||||||
muted: muted,
|
if (open != null) {
|
||||||
encrypted: encrypted,
|
c['open'] = boolToInt(open);
|
||||||
contactId: contactId,
|
}
|
||||||
contactAvatarPath: contactAvatarPath,
|
if (unreadCounter != null) {
|
||||||
contactDisplayName: contactDisplayName,
|
c['unreadCounter'] = unreadCounter;
|
||||||
sharedMediaAmount: sharedMediaAmount,
|
}
|
||||||
);
|
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
|
// Copy over the old lastMessage if a new one was not set
|
||||||
if (conversation.lastMessage != null && lastMessage == null) {
|
if (conversation.lastMessage != null && lastMessage == null) {
|
||||||
@@ -133,8 +206,9 @@ class ConversationService {
|
|||||||
return newConversation;
|
return newConversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the
|
/// Creates a [Conversation] inside the database given the data. This is so that the
|
||||||
/// cache.
|
/// [Conversation] object can carry its database id.
|
||||||
|
///
|
||||||
/// To prevent issues with the cache, only call from within
|
/// To prevent issues with the cache, only call from within
|
||||||
/// [ConversationService.createOrUpdateConversation].
|
/// [ConversationService.createOrUpdateConversation].
|
||||||
Future<Conversation> addConversationFromData(
|
Future<Conversation> addConversationFromData(
|
||||||
@@ -148,28 +222,34 @@ class ConversationService {
|
|||||||
bool open,
|
bool open,
|
||||||
bool muted,
|
bool muted,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
int sharedMediaAmount,
|
|
||||||
String? contactId,
|
String? contactId,
|
||||||
String? contactAvatarPath,
|
String? contactAvatarPath,
|
||||||
String? contactDisplayName,
|
String? contactDisplayName,
|
||||||
) async {
|
) async {
|
||||||
final newConversation =
|
final rosterItem =
|
||||||
await GetIt.I.get<DatabaseService>().addConversationFromData(
|
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
title,
|
final newConversation = Conversation(
|
||||||
lastMessage,
|
title,
|
||||||
type,
|
lastMessage,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
jid,
|
jid,
|
||||||
unreadCounter,
|
unreadCounter,
|
||||||
lastChangeTimestamp,
|
type,
|
||||||
open,
|
lastChangeTimestamp,
|
||||||
muted,
|
open,
|
||||||
encrypted,
|
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||||
sharedMediaAmount,
|
rosterItem?.subscription ?? 'none',
|
||||||
contactId,
|
muted,
|
||||||
contactAvatarPath,
|
encrypted,
|
||||||
contactDisplayName,
|
ChatState.gone,
|
||||||
);
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
|
);
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
conversationsTable,
|
||||||
|
newConversation.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
|
||||||
if (_conversationCache != null) {
|
if (_conversationCache != null) {
|
||||||
_conversationCache![newConversation.jid] = newConversation;
|
_conversationCache![newConversation.jid] = newConversation;
|
||||||
|
|||||||
@@ -58,11 +58,11 @@ class CryptographyService {
|
|||||||
return EncryptionResult(
|
return EncryptionResult(
|
||||||
key,
|
key,
|
||||||
iv,
|
iv,
|
||||||
<String, String>{
|
<HashFunction, String>{
|
||||||
hashSha256: base64Encode(result.plaintextHash),
|
HashFunction.sha256: base64Encode(result.plaintextHash),
|
||||||
},
|
},
|
||||||
<String, String>{
|
<HashFunction, String>{
|
||||||
hashSha256: base64Encode(result.ciphertextHash),
|
HashFunction.sha256: base64Encode(result.ciphertextHash),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,8 @@ class CryptographyService {
|
|||||||
SFSEncryptionType encryption,
|
SFSEncryptionType encryption,
|
||||||
List<int> key,
|
List<int> key,
|
||||||
List<int> iv,
|
List<int> iv,
|
||||||
Map<String, String> plaintextHashes,
|
Map<HashFunction, String> plaintextHashes,
|
||||||
Map<String, String> ciphertextHashes,
|
Map<HashFunction, String> ciphertextHashes,
|
||||||
) async {
|
) async {
|
||||||
_log.finest('Beginning decryption for $source');
|
_log.finest('Beginning decryption for $source');
|
||||||
final result = await MoxplatformPlugin.crypto.encryptFile(
|
final result = await MoxplatformPlugin.crypto.encryptFile(
|
||||||
@@ -94,7 +94,7 @@ class CryptographyService {
|
|||||||
var passedPlaintextIntegrityCheck = true;
|
var passedPlaintextIntegrityCheck = true;
|
||||||
var passedCiphertextIntegrityCheck = true;
|
var passedCiphertextIntegrityCheck = true;
|
||||||
for (final entry in plaintextHashes.entries) {
|
for (final entry in plaintextHashes.entries) {
|
||||||
if (entry.key == hashSha256) {
|
if (entry.key == HashFunction.sha256) {
|
||||||
if (base64Encode(result!.plaintextHash) != entry.value) {
|
if (base64Encode(result!.plaintextHash) != entry.value) {
|
||||||
passedPlaintextIntegrityCheck = false;
|
passedPlaintextIntegrityCheck = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -105,7 +105,7 @@ class CryptographyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (final entry in ciphertextHashes.entries) {
|
for (final entry in ciphertextHashes.entries) {
|
||||||
if (entry.key == hashSha256) {
|
if (entry.key == HashFunction.sha256) {
|
||||||
if (base64Encode(result!.ciphertextHash) != entry.value) {
|
if (base64Encode(result!.ciphertextHash) != entry.value) {
|
||||||
passedCiphertextIntegrityCheck = false;
|
passedCiphertextIntegrityCheck = false;
|
||||||
} else {
|
} 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> key;
|
||||||
final List<int> iv;
|
final List<int> iv;
|
||||||
|
|
||||||
final Map<String, String> plaintextHashes;
|
final Map<HashFunction, String> plaintextHashes;
|
||||||
final Map<String, String> ciphertextHashes;
|
final Map<HashFunction, String> ciphertextHashes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
@@ -52,13 +52,6 @@ class DecryptionRequest {
|
|||||||
final SFSEncryptionType encryption;
|
final SFSEncryptionType encryption;
|
||||||
final List<int> key;
|
final List<int> key;
|
||||||
final List<int> iv;
|
final List<int> iv;
|
||||||
final Map<String, String> plaintextHashes;
|
final Map<HashFunction, String> plaintextHashes;
|
||||||
final Map<String, String> ciphertextHashes;
|
final Map<HashFunction, String> ciphertextHashes;
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class HashRequest {
|
|
||||||
const HashRequest(this.path, this.hash);
|
|
||||||
final String path;
|
|
||||||
final HashFunction hash;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const stickersTable = 'Stickers';
|
|||||||
const stickerPacksTable = 'StickerPacks';
|
const stickerPacksTable = 'StickerPacks';
|
||||||
const blocklistTable = 'Blocklist';
|
const blocklistTable = 'Blocklist';
|
||||||
const subscriptionsTable = 'SubscriptionRequests';
|
const subscriptionsTable = 'SubscriptionRequests';
|
||||||
|
const fileMetadataTable = 'FileMetadata';
|
||||||
|
const fileMetadataHashesTable = 'FileMetadataHashes';
|
||||||
|
const reactionsTable = 'Reactions';
|
||||||
|
|
||||||
const typeString = 0;
|
const typeString = 0;
|
||||||
const typeInt = 1;
|
const typeInt = 1;
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
await db.execute(
|
await db.execute('''
|
||||||
'''
|
|
||||||
CREATE TABLE $messagesTable (
|
CREATE TABLE $messagesTable (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
sender TEXT NOT NULL,
|
sender TEXT NOT NULL,
|
||||||
@@ -27,41 +26,75 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
timestamp INTEGER NOT NULL,
|
timestamp INTEGER NOT NULL,
|
||||||
sid TEXT NOT NULL,
|
sid TEXT NOT NULL,
|
||||||
conversationJid TEXT NOT NULL,
|
conversationJid TEXT NOT NULL,
|
||||||
isMedia INTEGER NOT NULL,
|
|
||||||
isFileUploadNotification INTEGER NOT NULL,
|
isFileUploadNotification INTEGER NOT NULL,
|
||||||
encrypted INTEGER NOT NULL,
|
encrypted INTEGER NOT NULL,
|
||||||
errorType INTEGER,
|
errorType INTEGER,
|
||||||
warningType INTEGER,
|
warningType INTEGER,
|
||||||
mediaUrl TEXT,
|
|
||||||
mediaType TEXT,
|
|
||||||
thumbnailData TEXT,
|
|
||||||
mediaWidth INTEGER,
|
|
||||||
mediaHeight INTEGER,
|
|
||||||
srcUrl TEXT,
|
|
||||||
key TEXT,
|
|
||||||
iv TEXT,
|
|
||||||
encryptionScheme TEXT,
|
|
||||||
received INTEGER,
|
received INTEGER,
|
||||||
displayed INTEGER,
|
displayed INTEGER,
|
||||||
acked INTEGER,
|
acked INTEGER,
|
||||||
originId TEXT,
|
originId TEXT,
|
||||||
quote_id INTEGER,
|
quote_id INTEGER,
|
||||||
filename TEXT,
|
file_metadata_id TEXT,
|
||||||
plaintextHashes TEXT,
|
|
||||||
ciphertextHashes TEXT,
|
|
||||||
isDownloading INTEGER NOT NULL,
|
isDownloading INTEGER NOT NULL,
|
||||||
isUploading INTEGER NOT NULL,
|
isUploading INTEGER NOT NULL,
|
||||||
mediaSize INTEGER,
|
|
||||||
isRetracted INTEGER,
|
isRetracted INTEGER,
|
||||||
isEdited INTEGER NOT NULL,
|
isEdited INTEGER NOT NULL,
|
||||||
reactions TEXT NOT NULL,
|
|
||||||
containsNoStore INTEGER NOT NULL,
|
containsNoStore INTEGER NOT NULL,
|
||||||
stickerPackId TEXT,
|
stickerPackId TEXT,
|
||||||
stickerHashKey TEXT,
|
|
||||||
pseudoMessageType INTEGER,
|
pseudoMessageType INTEGER,
|
||||||
pseudoMessageData TEXT,
|
pseudoMessageData TEXT,
|
||||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
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
|
// Conversations
|
||||||
@@ -78,7 +111,6 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
muted INTEGER NOT NULL,
|
muted INTEGER NOT NULL,
|
||||||
encrypted INTEGER NOT NULL,
|
encrypted INTEGER NOT NULL,
|
||||||
lastMessageId INTEGER,
|
lastMessageId INTEGER,
|
||||||
sharedMediaAmount INTEGER NOT NULL,
|
|
||||||
contactId TEXT,
|
contactId TEXT,
|
||||||
contactAvatarPath TEXT,
|
contactAvatarPath TEXT,
|
||||||
contactDisplayName TEXT,
|
contactDisplayName TEXT,
|
||||||
@@ -87,6 +119,9 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
ON DELETE SET NULL
|
ON DELETE SET NULL
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||||
|
);
|
||||||
|
|
||||||
// Contacts
|
// Contacts
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
@@ -95,21 +130,6 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
jid TEXT NOT NULL
|
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
|
// Roster
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -134,19 +154,14 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
CREATE TABLE $stickersTable (
|
CREATE TABLE $stickersTable (
|
||||||
hashKey TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
mediaType TEXT NOT NULL,
|
desc TEXT NOT NULL,
|
||||||
desc TEXT NOT NULL,
|
suggests TEXT NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
file_metadata_id TEXT NOT NULL,
|
||||||
width INTEGER,
|
stickerPackId TEXT NOT NULL,
|
||||||
height INTEGER,
|
|
||||||
hashes TEXT NOT NULL,
|
|
||||||
urlSources TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
stickerPackId TEXT NOT NULL,
|
|
||||||
suggests TEXT NOT NULL,
|
|
||||||
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
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(
|
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/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/avatars.dart';
|
import 'package:moxxyv2/service/avatars.dart';
|
||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/contacts.dart';
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.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/database/helpers.dart';
|
||||||
import 'package:moxxyv2/service/helpers.dart';
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/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/notifications.dart';
|
||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/stickers.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/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.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/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.dart' as sticker;
|
||||||
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
|
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
|
||||||
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
//import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
void setupBackgroundEventHandler() {
|
void setupBackgroundEventHandler() {
|
||||||
final handler = EventHandler()
|
final handler = EventHandler()
|
||||||
@@ -97,6 +99,7 @@ void setupBackgroundEventHandler() {
|
|||||||
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
||||||
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
||||||
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
||||||
|
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@@ -151,17 +154,18 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
|
|||||||
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
||||||
|
|
||||||
// Check some permissions
|
// Check some permissions
|
||||||
final storagePerm = await Permission.storage.status;
|
// TODO(Unknown): Do we still need this permission?
|
||||||
final permissions = List<int>.empty(growable: true);
|
// final storagePerm = await Permission.storage.status;
|
||||||
if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
|
// final permissions = List<int>.empty(growable: true);
|
||||||
permissions.add(Permission.storage.value);
|
// if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
|
||||||
|
// permissions.add(Permission.storage.value);
|
||||||
|
|
||||||
await xss.modifyXmppState(
|
// await xss.modifyXmppState(
|
||||||
(state) => state.copyWith(
|
// (state) => state.copyWith(
|
||||||
askedStoragePermission: true,
|
// askedStoragePermission: true,
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
return PreStartDoneEvent(
|
return PreStartDoneEvent(
|
||||||
state: 'logged_in',
|
state: 'logged_in',
|
||||||
@@ -169,11 +173,12 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
|
|||||||
displayName: state.displayName ?? state.jid!.split('@').first,
|
displayName: state.displayName ?? state.jid!.split('@').first,
|
||||||
avatarUrl: state.avatarUrl,
|
avatarUrl: state.avatarUrl,
|
||||||
avatarHash: state.avatarHash,
|
avatarHash: state.avatarHash,
|
||||||
permissionsToRequest: permissions,
|
permissionsToRequest: [],
|
||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
conversations: (await GetIt.I.get<DatabaseService>().loadConversations())
|
conversations:
|
||||||
.where((c) => c.open)
|
(await GetIt.I.get<ConversationService>().loadConversations())
|
||||||
.toList(),
|
.where((c) => c.open)
|
||||||
|
.toList(),
|
||||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||||
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
|
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
|
||||||
);
|
);
|
||||||
@@ -240,7 +245,6 @@ Future<void> performAddConversation(
|
|||||||
true,
|
true,
|
||||||
preferences.defaultMuteState,
|
preferences.defaultMuteState,
|
||||||
preferences.enableOmemoByDefault,
|
preferences.enableOmemoByDefault,
|
||||||
0,
|
|
||||||
contactId,
|
contactId,
|
||||||
await css.getProfilePicturePathForJid(command.jid),
|
await css.getProfilePicturePathForJid(command.jid),
|
||||||
await css.getContactDisplayName(contactId),
|
await css.getContactDisplayName(contactId),
|
||||||
@@ -403,7 +407,9 @@ Future<void> performSetPreferences(
|
|||||||
final pm = GetIt.I
|
final pm = GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<PubSubManager>(pubsubManager)!;
|
.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 &&
|
if (command.preferences.isStickersNodePublic &&
|
||||||
!oldPrefs.isStickersNodePublic) {
|
!oldPrefs.isStickersNodePublic) {
|
||||||
// Set to open
|
// Set to open
|
||||||
@@ -471,7 +477,6 @@ Future<void> performAddContact(
|
|||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
prefs.enableOmemoByDefault,
|
prefs.enableOmemoByDefault,
|
||||||
0,
|
|
||||||
contactId,
|
contactId,
|
||||||
await css.getProfilePicturePathForJid(jid),
|
await css.getProfilePicturePathForJid(jid),
|
||||||
await css.getContactDisplayName(contactId),
|
await css.getContactDisplayName(contactId),
|
||||||
@@ -562,26 +567,33 @@ Future<void> performRequestDownload(
|
|||||||
);
|
);
|
||||||
sendEvent(MessageUpdatedEvent(message: message));
|
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
|
// TODO(Unknown): Maybe deduplicate with the code in the xmpp service
|
||||||
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
|
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
|
||||||
// for ".../aaaaaaaaa", in which case we would've failed anyways.
|
// 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);
|
final mimeGuess = metadata.mime ?? guessMimeTypeFromExtension(ext);
|
||||||
|
|
||||||
await srv.downloadFile(
|
await srv.downloadFile(
|
||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
MediaFileLocation(
|
MediaFileLocation(
|
||||||
message.srcUrl!,
|
fileMetadata.sourceUrls!,
|
||||||
message.filename ?? filenameFromUrl(message.srcUrl!),
|
fileMetadata.filename,
|
||||||
message.encryptionScheme,
|
fileMetadata.encryptionScheme,
|
||||||
message.key != null ? base64Decode(message.key!) : null,
|
fileMetadata.encryptionKey != null
|
||||||
message.iv != null ? base64Decode(message.iv!) : null,
|
? base64Decode(fileMetadata.encryptionKey!)
|
||||||
message.plaintextHashes,
|
: null,
|
||||||
message.ciphertextHashes,
|
fileMetadata.encryptionIv != null
|
||||||
|
? base64Decode(fileMetadata.encryptionIv!)
|
||||||
|
: null,
|
||||||
|
fileMetadata.plaintextHashes,
|
||||||
|
fileMetadata.ciphertextHashes,
|
||||||
|
null,
|
||||||
),
|
),
|
||||||
message.id,
|
message.id,
|
||||||
|
message.fileMetadata!.id,
|
||||||
message.conversationJid,
|
message.conversationJid,
|
||||||
mimeGuess,
|
mimeGuess,
|
||||||
),
|
),
|
||||||
@@ -655,6 +667,9 @@ Future<void> performSendChatState(
|
|||||||
// Only send chat states if the users wants to send them
|
// Only send chat states if the users wants to send them
|
||||||
if (!prefs.sendChatMarkers) return;
|
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>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
|
||||||
if (command.jid != '') {
|
if (command.jid != '') {
|
||||||
@@ -927,34 +942,32 @@ Future<void> performAddMessageReaction(
|
|||||||
AddReactionToMessageCommand command, {
|
AddReactionToMessageCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final rs = GetIt.I.get<ReactionsService>();
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final msg = await rs.addNewReaction(
|
||||||
final msg =
|
command.messageId,
|
||||||
await ms.getMessageById(command.conversationJid, command.messageId);
|
command.conversationJid,
|
||||||
assert(msg != null, 'The message must be found');
|
command.emoji,
|
||||||
|
);
|
||||||
// Update the state
|
if (msg == null) {
|
||||||
final reactions = List<Reaction>.from(msg!.reactions);
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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 != '') {
|
if (command.conversationJid != '') {
|
||||||
|
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||||
|
|
||||||
// Send the reaction
|
// Send the reaction
|
||||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<MessageManager>(messageManager)!
|
||||||
|
.sendMessage(
|
||||||
MessageDetails(
|
MessageDetails(
|
||||||
to: command.conversationJid,
|
to: command.conversationJid,
|
||||||
messageReactions: MessageReactions(
|
messageReactions: MessageReactions(
|
||||||
msg.originId ?? msg.sid,
|
msg.originId ?? msg.sid,
|
||||||
ownReactions,
|
await rs.getReactionsForMessageByJid(
|
||||||
|
command.messageId,
|
||||||
|
jid,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requestChatMarkers: false,
|
requestChatMarkers: false,
|
||||||
messageProcessingHints:
|
messageProcessingHints:
|
||||||
@@ -968,35 +981,32 @@ Future<void> performRemoveMessageReaction(
|
|||||||
RemoveReactionFromMessageCommand command, {
|
RemoveReactionFromMessageCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final rs = GetIt.I.get<ReactionsService>();
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final msg = await rs.removeReaction(
|
||||||
final msg =
|
command.messageId,
|
||||||
await ms.getMessageById(command.conversationJid, command.messageId);
|
command.conversationJid,
|
||||||
assert(msg != null, 'The message must be found');
|
command.emoji,
|
||||||
|
);
|
||||||
// Update the state
|
if (msg == null) {
|
||||||
final reactions = List<Reaction>.from(msg!.reactions);
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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 != '') {
|
if (command.conversationJid != '') {
|
||||||
|
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||||
|
|
||||||
// Send the reaction
|
// Send the reaction
|
||||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<MessageManager>(messageManager)!
|
||||||
|
.sendMessage(
|
||||||
MessageDetails(
|
MessageDetails(
|
||||||
to: command.conversationJid,
|
to: command.conversationJid,
|
||||||
messageReactions: MessageReactions(
|
messageReactions: MessageReactions(
|
||||||
msg.originId ?? msg.sid,
|
msg.originId ?? msg.sid,
|
||||||
ownReactions,
|
await rs.getReactionsForMessageByJid(
|
||||||
|
command.messageId,
|
||||||
|
jid,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requestChatMarkers: false,
|
requestChatMarkers: false,
|
||||||
messageProcessingHints:
|
messageProcessingHints:
|
||||||
@@ -1042,21 +1052,13 @@ Future<void> performSendSticker(
|
|||||||
SendStickerCommand command, {
|
SendStickerCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
final xs = GetIt.I.get<XmppService>();
|
await GetIt.I.get<XmppService>().sendMessage(
|
||||||
final ss = GetIt.I.get<StickersService>();
|
body: command.sticker.desc,
|
||||||
|
recipients: [command.recipient],
|
||||||
final sticker = await ss.getStickerByHashKey(
|
sticker: command.sticker,
|
||||||
command.stickerPackId,
|
currentConversationJid: command.recipient,
|
||||||
command.stickerHashKey,
|
quotedMessage: command.quotes,
|
||||||
);
|
);
|
||||||
assert(sticker != null, 'Sticker not found');
|
|
||||||
|
|
||||||
await xs.sendMessage(
|
|
||||||
body: sticker!.desc,
|
|
||||||
recipients: [command.recipient],
|
|
||||||
sticker: sticker,
|
|
||||||
currentConversationJid: command.recipient,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performRemoveStickerPack(
|
Future<void> performRemoveStickerPack(
|
||||||
@@ -1096,19 +1098,30 @@ Future<void> performFetchStickerPack(
|
|||||||
.map(
|
.map(
|
||||||
(s) => sticker.Sticker(
|
(s) => sticker.Sticker(
|
||||||
'',
|
'',
|
||||||
s.metadata.mediaType!,
|
|
||||||
s.metadata.desc!,
|
|
||||||
s.metadata.size!,
|
|
||||||
s.metadata.width,
|
|
||||||
s.metadata.height,
|
|
||||||
s.metadata.hashes,
|
|
||||||
s.sources
|
|
||||||
.whereType<StatelessFileSharingUrlSource>()
|
|
||||||
.map((src) => src.url)
|
|
||||||
.toList(),
|
|
||||||
'',
|
|
||||||
command.stickerPackId,
|
command.stickerPackId,
|
||||||
|
s.metadata.desc!,
|
||||||
s.suggests,
|
s.suggests,
|
||||||
|
FileMetadata(
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
s.sources
|
||||||
|
.whereType<StatelessFileSharingUrlSource>()
|
||||||
|
.map((src) => src.url)
|
||||||
|
.toList(),
|
||||||
|
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(),
|
.toList(),
|
||||||
@@ -1188,15 +1201,49 @@ Future<void> performGetPagedSharedMedia(
|
|||||||
final id = extra as String;
|
final id = extra as String;
|
||||||
|
|
||||||
final result =
|
final result =
|
||||||
await GetIt.I.get<DatabaseService>().getPaginatedSharedMediaForJid(
|
await GetIt.I.get<MessageService>().getPaginatedSharedMediaMessagesForJid(
|
||||||
command.conversationJid,
|
command.conversationJid,
|
||||||
command.olderThan,
|
command.olderThan,
|
||||||
command.timestamp,
|
command.timestamp,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
PagedSharedMediaResultEvent(
|
PagedMessagesResultEvent(
|
||||||
media: result,
|
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,
|
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;
|
return t.errors.login.unspecified;
|
||||||
}
|
}
|
||||||
|
|
||||||
String getStickerHashKeyType(Map<String, String> hashes) {
|
HashFunction getStickerHashKeyType(Map<HashFunction, String> hashes) {
|
||||||
if (hashes.containsKey('blake2b-512')) {
|
if (hashes.containsKey(HashFunction.blake2b512)) {
|
||||||
return 'blake2b-512';
|
return HashFunction.blake2b512;
|
||||||
} else if (hashes.containsKey('blake2b-512')) {
|
} else if (hashes.containsKey(HashFunction.blake2b256)) {
|
||||||
return 'blake2b-256';
|
return HashFunction.blake2b256;
|
||||||
} else if (hashes.containsKey('sha3-512')) {
|
} else if (hashes.containsKey(HashFunction.sha3_512)) {
|
||||||
return 'sha3-512';
|
return HashFunction.sha3_512;
|
||||||
} else if (hashes.containsKey('sha3-256')) {
|
} else if (hashes.containsKey(HashFunction.sha3_256)) {
|
||||||
return 'sha3-256';
|
return HashFunction.sha3_256;
|
||||||
} else if (hashes.containsKey('sha3-256')) {
|
} else if (hashes.containsKey(HashFunction.sha512)) {
|
||||||
return 'sha-512';
|
return HashFunction.sha512;
|
||||||
} else if (hashes.containsKey('sha-256')) {
|
} else if (hashes.containsKey(HashFunction.sha256)) {
|
||||||
return 'sha-256';
|
return HashFunction.sha256;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(false, 'No valid hash found');
|
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);
|
final key = getStickerHashKeyType(hashes);
|
||||||
return '$key:${hashes[key]}';
|
return '$key:${hashes[key]}';
|
||||||
}
|
}
|
||||||
@@ -131,16 +132,19 @@ String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
|||||||
if (quotedMessage.isMedia) {
|
if (quotedMessage.isMedia) {
|
||||||
// Create formatted size string, if size is stored
|
// Create formatted size string, if size is stored
|
||||||
String quoteMessageSize;
|
String quoteMessageSize;
|
||||||
if (quotedMessage.mediaSize != null && quotedMessage.mediaSize! > 0) {
|
if (quotedMessage.fileMetadata!.size != null &&
|
||||||
quoteMessageSize = '(${fileSizeToString(quotedMessage.mediaSize!)}) ';
|
quotedMessage.fileMetadata!.size! > 0) {
|
||||||
|
quoteMessageSize =
|
||||||
|
'(${fileSizeToString(quotedMessage.fileMetadata!.size!)}) ';
|
||||||
} else {
|
} else {
|
||||||
quoteMessageSize = '';
|
quoteMessageSize = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create media url string, or use body if no srcUrl is stored
|
// Create media url string, or use body if no srcUrl is stored
|
||||||
String quotedMediaUrl;
|
String quotedMediaUrl;
|
||||||
if (quotedMessage.srcUrl != null && quotedMessage.srcUrl!.isNotEmpty) {
|
if (quotedMessage.fileMetadata!.sourceUrls != null &&
|
||||||
quotedMediaUrl = '• ${quotedMessage.srcUrl!}';
|
quotedMessage.fileMetadata!.sourceUrls!.first.isNotEmpty) {
|
||||||
|
quotedMediaUrl = '• ${quotedMessage.fileMetadata!.sourceUrls!.first}';
|
||||||
} else if (quotedMessage.body.isNotEmpty) {
|
} else if (quotedMessage.body.isNotEmpty) {
|
||||||
quotedMediaUrl = '• ${quotedMessage.body}';
|
quotedMediaUrl = '• ${quotedMessage.body}';
|
||||||
} else {
|
} 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/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].
|
/// Returns true if the request was successful based on [statusCode].
|
||||||
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
/// 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;
|
return statusCode != null && statusCode >= 200 && statusCode <= 399;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileMetadata {
|
class FileUploadMetadata {
|
||||||
const FileMetadata({this.mime, this.size});
|
const FileUploadMetadata({this.mime, this.size});
|
||||||
final String? mime;
|
final String? mime;
|
||||||
final int? size;
|
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
|
/// 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.
|
/// does not specify the Content-Length header, null is returned.
|
||||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
/// 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));
|
final result = await peekUrl(Uri.parse(url));
|
||||||
|
|
||||||
return FileMetadata(
|
return FileUploadMetadata(
|
||||||
mime: result?.contentType,
|
mime: result?.contentType,
|
||||||
size: result?.contentLength,
|
size: result?.contentLength,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
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/conversation.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/types.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/client.dart' as client;
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.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/message.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
|
||||||
import 'package:moxxyv2/shared/warning_types.dart';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -122,29 +121,25 @@ class HttpFileTransferService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _copyFile(FileUploadJob job) async {
|
Future<void> _copyFile(
|
||||||
for (final recipient in job.recipients) {
|
FileUploadJob job,
|
||||||
final newPath = await getDownloadPath(
|
String to,
|
||||||
pathlib.basename(job.path),
|
) async {
|
||||||
recipient,
|
if (!File(to).existsSync()) {
|
||||||
job.mime,
|
await File(job.path).copy(to);
|
||||||
);
|
|
||||||
|
|
||||||
await File(job.path).copy(newPath);
|
|
||||||
|
|
||||||
// Let the media scanner index the file
|
// Let the media scanner index the file
|
||||||
MoxplatformPlugin.media.scanFile(newPath);
|
MoxplatformPlugin.media.scanFile(to);
|
||||||
|
} else {
|
||||||
// Update the message
|
_log.finest(
|
||||||
await GetIt.I.get<MessageService>().updateMessage(
|
'Skipping file copy on upload as file is already at media location',
|
||||||
job.messageMap[recipient]!.id,
|
);
|
||||||
mediaUrl: newPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
|
||||||
// Notify UI of upload failure
|
// Notify UI of upload failure
|
||||||
for (final recipient in job.recipients) {
|
for (final recipient in job.recipients) {
|
||||||
@@ -154,6 +149,19 @@ class HttpFileTransferService {
|
|||||||
isUploading: false,
|
isUploading: false,
|
||||||
);
|
);
|
||||||
sendEvent(MessageUpdatedEvent(message: msg));
|
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();
|
await _pickNextUploadTask();
|
||||||
@@ -233,20 +241,92 @@ class HttpFileTransferService {
|
|||||||
} else {
|
} else {
|
||||||
_log.fine('Upload was successful');
|
_log.fine('Upload was successful');
|
||||||
|
|
||||||
|
// Get hashes
|
||||||
|
StatelessFileSharingSource source;
|
||||||
|
final plaintextHashes = <HashFunction, String>{};
|
||||||
|
Map<HashFunction, String>? ciphertextHashes;
|
||||||
|
if (encryption != null) {
|
||||||
|
source = StatelessFileSharingEncryptedSource(
|
||||||
|
SFSEncryptionType.aes256GcmNoPadding,
|
||||||
|
encryption.key,
|
||||||
|
encryption.iv,
|
||||||
|
encryption.ciphertextHashes,
|
||||||
|
StatelessFileSharingUrlSource(slot.getUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||||
|
ciphertextHashes = encryption.ciphertextHashes;
|
||||||
|
} else {
|
||||||
|
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||||
|
try {
|
||||||
|
plaintextHashes[HashFunction.sha256] = await GetIt.I
|
||||||
|
.get<CryptographyService>()
|
||||||
|
.hashFile(job.path, HashFunction.sha256);
|
||||||
|
} catch (ex) {
|
||||||
|
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
const uuid = Uuid();
|
||||||
for (final recipient in job.recipients) {
|
for (final recipient in job.recipients) {
|
||||||
// Notify UI of upload completion
|
// Notify UI of upload completion
|
||||||
var msg = await ms.updateMessage(
|
var msg = await ms.updateMessage(
|
||||||
job.messageMap[recipient]!.id,
|
job.messageMap[recipient]!.id,
|
||||||
mediaSize: stat.size,
|
|
||||||
errorType: noError,
|
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,
|
isUploading: false,
|
||||||
srcUrl: slot.getUrl,
|
fileMetadata: metadata,
|
||||||
);
|
);
|
||||||
// TODO(Unknown): Maybe batch those two together?
|
// TODO(Unknown): Maybe batch those two together?
|
||||||
final oldSid = msg.sid;
|
final oldSid = msg.sid;
|
||||||
@@ -257,29 +337,6 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
sendEvent(MessageUpdatedEvent(message: msg));
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
StatelessFileSharingSource source;
|
|
||||||
final plaintextHashes = <String, String>{};
|
|
||||||
if (encryption != null) {
|
|
||||||
source = StatelessFileSharingEncryptedSource(
|
|
||||||
SFSEncryptionType.aes256GcmNoPadding,
|
|
||||||
encryption.key,
|
|
||||||
encryption.iv,
|
|
||||||
encryption.ciphertextHashes,
|
|
||||||
StatelessFileSharingUrlSource(slot.getUrl),
|
|
||||||
);
|
|
||||||
|
|
||||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
|
||||||
} else {
|
|
||||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
|
||||||
try {
|
|
||||||
plaintextHashes[hashSha256] = await GetIt.I
|
|
||||||
.get<CryptographyService>()
|
|
||||||
.hashFile(job.path, HashFunction.sha256);
|
|
||||||
} catch (ex) {
|
|
||||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the message to the recipient
|
// Send the message to the recipient
|
||||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
MessageDetails(
|
MessageDetails(
|
||||||
@@ -292,7 +349,7 @@ class HttpFileTransferService {
|
|||||||
FileMetadataData(
|
FileMetadataData(
|
||||||
mediaType: job.mime,
|
mediaType: job.mime,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
name: pathlib.basename(job.path),
|
name: filename,
|
||||||
thumbnails: job.thumbnails,
|
thumbnails: job.thumbnails,
|
||||||
hashes: plaintextHashes,
|
hashes: plaintextHashes,
|
||||||
),
|
),
|
||||||
@@ -305,15 +362,14 @@ class HttpFileTransferService {
|
|||||||
_log.finest(
|
_log.finest(
|
||||||
'Sent message with file upload for ${job.path} to $recipient',
|
'Sent message with file upload for ${job.path} to $recipient',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final isMultiMedia = (job.mime?.startsWith('image/') ?? false) ||
|
// Remove the old metadata only here because we would otherwise violate a foreign key
|
||||||
(job.mime?.startsWith('video/') ?? false);
|
// constraint.
|
||||||
if (isMultiMedia) {
|
if (metadataWrapper.retrieved) {
|
||||||
_log.finest(
|
await GetIt.I.get<FilesService>().removeFileMetadata(
|
||||||
'File appears to be either an image or a video. Copying it to the correct directory...',
|
job.metadataId,
|
||||||
);
|
);
|
||||||
unawaited(_copyFile(job));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,8 +407,10 @@ class HttpFileTransferService {
|
|||||||
/// Actually attempt to download the file described by the job [job].
|
/// Actually attempt to download the file described by the job [job].
|
||||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||||
final filename = job.location.filename;
|
final filename = job.location.filename;
|
||||||
final downloadedPath =
|
final downloadedPath = await computeCachedPathForFile(
|
||||||
await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
job.location.filename,
|
||||||
|
job.location.plaintextHashes,
|
||||||
|
);
|
||||||
|
|
||||||
var downloadPath = downloadedPath;
|
var downloadPath = downloadedPath;
|
||||||
if (job.location.key != null && job.location.iv != null) {
|
if (job.location.key != null && job.location.iv != null) {
|
||||||
@@ -361,15 +419,18 @@ class HttpFileTransferService {
|
|||||||
downloadPath = pathlib.join(tempDir.path, filename);
|
downloadPath = pathlib.join(tempDir.path, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(Unknown): Maybe try other URLs?
|
||||||
|
final downloadUrl = job.location.urls.first;
|
||||||
_log.finest(
|
_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;
|
int? downloadStatusCode;
|
||||||
|
var integrityCheckPassed = true;
|
||||||
try {
|
try {
|
||||||
_log.finest('Beginning download...');
|
_log.finest('Beginning download...');
|
||||||
downloadStatusCode = await client.downloadFile(
|
downloadStatusCode = await client.downloadFile(
|
||||||
Uri.parse(job.location.url),
|
Uri.parse(downloadUrl),
|
||||||
downloadPath,
|
downloadPath,
|
||||||
(total, current) {
|
(total, current) {
|
||||||
final progress = current.toDouble() / total.toDouble();
|
final progress = current.toDouble() / total.toDouble();
|
||||||
@@ -388,18 +449,15 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
if (!isRequestOkay(downloadStatusCode)) {
|
if (!isRequestOkay(downloadStatusCode)) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
'HTTP GET of ${job.location.url} returned $downloadStatusCode',
|
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||||
);
|
);
|
||||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var integrityCheckPassed = true;
|
|
||||||
final conv = (await GetIt.I
|
|
||||||
.get<ConversationService>()
|
|
||||||
.getConversationByJid(job.conversationJid))!;
|
|
||||||
final decryptionKeysAvailable =
|
final decryptionKeysAvailable =
|
||||||
job.location.key != null && job.location.iv != null;
|
job.location.key != null && job.location.iv != null;
|
||||||
|
final crypto = GetIt.I.get<CryptographyService>();
|
||||||
if (decryptionKeysAvailable) {
|
if (decryptionKeysAvailable) {
|
||||||
// The file was downloaded and is now being decrypted
|
// The file was downloaded and is now being decrypted
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@@ -409,15 +467,15 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
final result = await crypto.decryptFile(
|
||||||
downloadPath,
|
downloadPath,
|
||||||
downloadedPath,
|
downloadedPath,
|
||||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
|
||||||
job.location.key!,
|
job.location.key!,
|
||||||
job.location.iv!,
|
job.location.iv!,
|
||||||
job.location.plaintextHashes ?? {},
|
job.location.plaintextHashes ?? {},
|
||||||
job.location.ciphertextHashes ?? {},
|
job.location.ciphertextHashes ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.decryptionOkay) {
|
if (!result.decryptionOkay) {
|
||||||
_log.warning('Failed to decrypt $downloadPath');
|
_log.warning('Failed to decrypt $downloadPath');
|
||||||
@@ -437,6 +495,28 @@ class HttpFileTransferService {
|
|||||||
unawaited(
|
unawaited(
|
||||||
Directory(pathlib.dirname(downloadPath)).delete(recursive: true),
|
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
|
// 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(
|
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||||
job.mId,
|
job.mId,
|
||||||
mediaUrl: downloadedPath,
|
fileMetadata: metadata,
|
||||||
mediaType: mime,
|
|
||||||
mediaWidth: mediaWidth,
|
|
||||||
mediaHeight: mediaHeight,
|
|
||||||
mediaSize: File(downloadedPath).lengthSync(),
|
|
||||||
isFileUploadNotification: false,
|
isFileUploadNotification: false,
|
||||||
warningType:
|
warningType:
|
||||||
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||||
errorType: conv.encrypted && !decryptionKeysAvailable
|
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||||
? messageChatEncryptedButFileNot
|
? messageChatEncryptedButFileNot
|
||||||
: null,
|
: null,
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
@@ -498,47 +598,21 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
sendEvent(MessageUpdatedEvent(message: msg));
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
final sharedMedium =
|
final updatedConversation = conversation.copyWith(
|
||||||
await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
lastMessage: conversation.lastMessage?.id == job.mId
|
||||||
downloadedPath,
|
? msg
|
||||||
msg.timestamp,
|
: conversation.lastMessage,
|
||||||
conv.jid,
|
|
||||||
job.mId,
|
|
||||||
mime: mime,
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
cs.setConversation(updatedConversation);
|
||||||
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);
|
|
||||||
|
|
||||||
// Show a notification
|
// Show a notification
|
||||||
if (notification.shouldShowNotification(msg.conversationJid) &&
|
if (notification.shouldShowNotification(msg.conversationJid) &&
|
||||||
job.shouldShowNotification) {
|
job.shouldShowNotification) {
|
||||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
_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
|
// Free the download resources for the next one
|
||||||
await _pickNextDownloadTask();
|
await _pickNextDownloadTask();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class FileUploadJob {
|
|||||||
this.mime,
|
this.mime,
|
||||||
this.encryptMap,
|
this.encryptMap,
|
||||||
this.messageMap,
|
this.messageMap,
|
||||||
|
this.metadataId,
|
||||||
this.thumbnails,
|
this.thumbnails,
|
||||||
);
|
);
|
||||||
final List<String> recipients;
|
final List<String> recipients;
|
||||||
@@ -21,6 +22,7 @@ class FileUploadJob {
|
|||||||
final Map<String, bool> encryptMap;
|
final Map<String, bool> encryptMap;
|
||||||
// Recipient -> Message
|
// Recipient -> Message
|
||||||
final Map<String, Message> messageMap;
|
final Map<String, Message> messageMap;
|
||||||
|
final String metadataId;
|
||||||
final List<Thumbnail> thumbnails;
|
final List<Thumbnail> thumbnails;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -31,7 +33,8 @@ class FileUploadJob {
|
|||||||
messageMap == other.messageMap &&
|
messageMap == other.messageMap &&
|
||||||
mime == other.mime &&
|
mime == other.mime &&
|
||||||
thumbnails == other.thumbnails &&
|
thumbnails == other.thumbnails &&
|
||||||
encryptMap == other.encryptMap;
|
encryptMap == other.encryptMap &&
|
||||||
|
metadataId == other.metadataId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -41,7 +44,8 @@ class FileUploadJob {
|
|||||||
messageMap.hashCode ^
|
messageMap.hashCode ^
|
||||||
mime.hashCode ^
|
mime.hashCode ^
|
||||||
thumbnails.hashCode ^
|
thumbnails.hashCode ^
|
||||||
encryptMap.hashCode;
|
encryptMap.hashCode ^
|
||||||
|
metadataId.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A job describing the upload of a file.
|
/// A job describing the upload of a file.
|
||||||
@@ -50,12 +54,14 @@ class FileDownloadJob {
|
|||||||
const FileDownloadJob(
|
const FileDownloadJob(
|
||||||
this.location,
|
this.location,
|
||||||
this.mId,
|
this.mId,
|
||||||
|
this.metadataId,
|
||||||
this.conversationJid,
|
this.conversationJid,
|
||||||
this.mimeGuess, {
|
this.mimeGuess, {
|
||||||
this.shouldShowNotification = true,
|
this.shouldShowNotification = true,
|
||||||
});
|
});
|
||||||
final MediaFileLocation location;
|
final MediaFileLocation location;
|
||||||
final int mId;
|
final int mId;
|
||||||
|
final String metadataId;
|
||||||
final String conversationJid;
|
final String conversationJid;
|
||||||
final String? mimeGuess;
|
final String? mimeGuess;
|
||||||
final bool shouldShowNotification;
|
final bool shouldShowNotification;
|
||||||
@@ -65,6 +71,7 @@ class FileDownloadJob {
|
|||||||
return other is FileDownloadJob &&
|
return other is FileDownloadJob &&
|
||||||
location == other.location &&
|
location == other.location &&
|
||||||
mId == other.mId &&
|
mId == other.mId &&
|
||||||
|
metadataId == other.metadataId &&
|
||||||
conversationJid == other.conversationJid &&
|
conversationJid == other.conversationJid &&
|
||||||
mimeGuess == other.mimeGuess &&
|
mimeGuess == other.mimeGuess &&
|
||||||
shouldShowNotification == other.shouldShowNotification;
|
shouldShowNotification == other.shouldShowNotification;
|
||||||
@@ -74,6 +81,7 @@ class FileDownloadJob {
|
|||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
location.hashCode ^
|
location.hashCode ^
|
||||||
mId.hashCode ^
|
mId.hashCode ^
|
||||||
|
metadataId.hashCode ^
|
||||||
conversationJid.hashCode ^
|
conversationJid.hashCode ^
|
||||||
mimeGuess.hashCode ^
|
mimeGuess.hashCode ^
|
||||||
shouldShowNotification.hashCode;
|
shouldShowNotification.hashCode;
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class MediaFileLocation {
|
class MediaFileLocation {
|
||||||
const MediaFileLocation(
|
const MediaFileLocation(
|
||||||
this.url,
|
this.urls,
|
||||||
this.filename,
|
this.filename,
|
||||||
this.encryptionScheme,
|
this.encryptionScheme,
|
||||||
this.key,
|
this.key,
|
||||||
this.iv,
|
this.iv,
|
||||||
this.plaintextHashes,
|
this.plaintextHashes,
|
||||||
this.ciphertextHashes,
|
this.ciphertextHashes,
|
||||||
|
this.size,
|
||||||
);
|
);
|
||||||
final String url;
|
final List<String> urls;
|
||||||
final String filename;
|
final String filename;
|
||||||
final String? encryptionScheme;
|
final String? encryptionScheme;
|
||||||
final List<int>? key;
|
final List<int>? key;
|
||||||
final List<int>? iv;
|
final List<int>? iv;
|
||||||
final Map<String, String>? plaintextHashes;
|
final Map<HashFunction, String>? plaintextHashes;
|
||||||
final Map<String, String>? ciphertextHashes;
|
final Map<HashFunction, String>? ciphertextHashes;
|
||||||
|
final int? size;
|
||||||
|
|
||||||
String? get keyBase64 {
|
String? get keyBase64 {
|
||||||
if (key != null) return base64Encode(key!);
|
if (key != null) return base64Encode(key!);
|
||||||
@@ -34,22 +37,23 @@ class MediaFileLocation {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
url.hashCode ^
|
urls.hashCode ^
|
||||||
filename.hashCode ^
|
filename.hashCode ^
|
||||||
encryptionScheme.hashCode ^
|
encryptionScheme.hashCode ^
|
||||||
key.hashCode ^
|
key.hashCode ^
|
||||||
iv.hashCode ^
|
iv.hashCode ^
|
||||||
plaintextHashes.hashCode ^
|
plaintextHashes.hashCode ^
|
||||||
ciphertextHashes.hashCode;
|
ciphertextHashes.hashCode ^
|
||||||
|
size.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
// TODO(PapaTutuWawa): Compare the Maps
|
// TODO(PapaTutuWawa): Compare the Maps
|
||||||
return other is MediaFileLocation &&
|
return other is MediaFileLocation &&
|
||||||
url == other.url &&
|
|
||||||
filename == other.filename &&
|
filename == other.filename &&
|
||||||
encryptionScheme == other.encryptionScheme &&
|
encryptionScheme == other.encryptionScheme &&
|
||||||
key == other.key &&
|
key == other.key &&
|
||||||
iv == other.iv;
|
iv == other.iv &&
|
||||||
|
size == other.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.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/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/not_specified.dart';
|
||||||
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/cache.dart';
|
import 'package:moxxyv2/shared/cache.dart';
|
||||||
import 'package:moxxyv2/shared/constants.dart';
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.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:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
@@ -23,6 +26,97 @@ class MessageService {
|
|||||||
LRUCache(conversationMessagePageCacheSize);
|
LRUCache(conversationMessagePageCacheSize);
|
||||||
final Lock _cacheLock = Lock();
|
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
|
/// 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
|
/// 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.
|
/// than [oldestTimestamp], or the newest messages are returned if null.
|
||||||
@@ -38,12 +132,108 @@ class MessageService {
|
|||||||
if (result != null) return result;
|
if (result != null) return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
final page =
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
await GetIt.I.get<DatabaseService>().getPaginatedMessagesForJid(
|
final comparator = olderThan ? '<' : '>';
|
||||||
jid,
|
final query = oldestTimestamp != null
|
||||||
olderThan,
|
? 'conversationJid = ? AND timestamp $comparator ?'
|
||||||
oldestTimestamp,
|
: '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,
|
||||||
|
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) {
|
if (olderThan && oldestTimestamp == null) {
|
||||||
await _cacheLock.synchronized(() {
|
await _cacheLock.synchronized(() {
|
||||||
@@ -57,79 +247,130 @@ class MessageService {
|
|||||||
return page;
|
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.
|
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
|
||||||
Future<Message> addMessageFromData(
|
Future<Message> addMessageFromData(
|
||||||
String body,
|
String body,
|
||||||
int timestamp,
|
int timestamp,
|
||||||
String sender,
|
String sender,
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
bool isMedia,
|
|
||||||
String sid,
|
String sid,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
bool containsNoStore, {
|
bool containsNoStore, {
|
||||||
String? srcUrl,
|
|
||||||
String? key,
|
|
||||||
String? iv,
|
|
||||||
String? encryptionScheme,
|
|
||||||
String? mediaUrl,
|
|
||||||
String? mediaType,
|
|
||||||
String? thumbnailData,
|
|
||||||
int? mediaWidth,
|
|
||||||
int? mediaHeight,
|
|
||||||
String? originId,
|
String? originId,
|
||||||
String? quoteId,
|
String? quoteId,
|
||||||
String? filename,
|
FileMetadata? fileMetadata,
|
||||||
int? errorType,
|
int? errorType,
|
||||||
int? warningType,
|
int? warningType,
|
||||||
Map<String, String>? plaintextHashes,
|
|
||||||
Map<String, String>? ciphertextHashes,
|
|
||||||
bool isDownloading = false,
|
bool isDownloading = false,
|
||||||
bool isUploading = false,
|
bool isUploading = false,
|
||||||
int? mediaSize,
|
|
||||||
String? stickerPackId,
|
String? stickerPackId,
|
||||||
String? stickerHashKey,
|
|
||||||
int? pseudoMessageType,
|
int? pseudoMessageType,
|
||||||
Map<String, dynamic>? pseudoMessageData,
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
bool received = false,
|
bool received = false,
|
||||||
bool displayed = false,
|
bool displayed = false,
|
||||||
}) async {
|
}) async {
|
||||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
body,
|
var m = Message(
|
||||||
timestamp,
|
sender,
|
||||||
sender,
|
body,
|
||||||
conversationJid,
|
timestamp,
|
||||||
isMedia,
|
sid,
|
||||||
sid,
|
-1,
|
||||||
isFileUploadNotification,
|
conversationJid,
|
||||||
encrypted,
|
isFileUploadNotification,
|
||||||
containsNoStore,
|
encrypted,
|
||||||
srcUrl: srcUrl,
|
containsNoStore,
|
||||||
key: key,
|
errorType: errorType,
|
||||||
iv: iv,
|
warningType: warningType,
|
||||||
encryptionScheme: encryptionScheme,
|
fileMetadata: fileMetadata,
|
||||||
mediaUrl: mediaUrl,
|
received: received,
|
||||||
mediaType: mediaType,
|
displayed: displayed,
|
||||||
thumbnailData: thumbnailData,
|
acked: false,
|
||||||
mediaWidth: mediaWidth,
|
originId: originId,
|
||||||
mediaHeight: mediaHeight,
|
isUploading: isUploading,
|
||||||
originId: originId,
|
isDownloading: isDownloading,
|
||||||
quoteId: quoteId,
|
stickerPackId: stickerPackId,
|
||||||
filename: filename,
|
pseudoMessageType: pseudoMessageType,
|
||||||
errorType: errorType,
|
pseudoMessageData: pseudoMessageData,
|
||||||
warningType: warningType,
|
);
|
||||||
plaintextHashes: plaintextHashes,
|
|
||||||
ciphertextHashes: ciphertextHashes,
|
if (quoteId != null) {
|
||||||
isUploading: isUploading,
|
final quotes = await getMessageByXmppId(quoteId, conversationJid);
|
||||||
isDownloading: isDownloading,
|
if (quotes == null) {
|
||||||
mediaSize: mediaSize,
|
_log.warning('Failed to add quote for message with id $quoteId');
|
||||||
stickerPackId: stickerPackId,
|
} else {
|
||||||
stickerHashKey: stickerHashKey,
|
m = m.copyWith(quotes: quotes);
|
||||||
pseudoMessageType: pseudoMessageType,
|
}
|
||||||
pseudoMessageData: pseudoMessageData,
|
}
|
||||||
received: received,
|
|
||||||
displayed: displayed,
|
m = m.copyWith(
|
||||||
);
|
id: await db.insert(messagesTable, m.toDatabaseJson()),
|
||||||
|
);
|
||||||
|
|
||||||
await _cacheLock.synchronized(() {
|
await _cacheLock.synchronized(() {
|
||||||
final cachedList = _messageCache.getValue(conversationJid);
|
final cachedList = _messageCache.getValue(conversationJid);
|
||||||
@@ -138,101 +379,137 @@ class MessageService {
|
|||||||
conversationJid,
|
conversationJid,
|
||||||
clampedListPrepend(
|
clampedListPrepend(
|
||||||
cachedList,
|
cachedList,
|
||||||
msg,
|
m,
|
||||||
messagePaginationSize,
|
messagePaginationSize,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return msg;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Message?> getMessageByStanzaId(
|
Future<Message?> getMessageByStanzaId(
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
String stanzaId,
|
String stanzaId,
|
||||||
) async {
|
) async {
|
||||||
return GetIt.I.get<DatabaseService>().getMessageByXmppId(
|
return getMessageByXmppId(
|
||||||
stanzaId,
|
stanzaId,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
includeOriginId: false,
|
includeOriginId: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Message?> getMessageByStanzaOrOriginId(
|
Future<Message?> getMessageByStanzaOrOriginId(
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
String id,
|
String id,
|
||||||
) async {
|
) async {
|
||||||
return GetIt.I.get<DatabaseService>().getMessageByXmppId(
|
return getMessageByXmppId(
|
||||||
id,
|
id,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
Future<Message?> getMessageById(String conversationJid, int id) async {
|
|
||||||
return GetIt.I.get<DatabaseService>().getMessageById(
|
|
||||||
id,
|
|
||||||
conversationJid,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
||||||
Future<Message> updateMessage(
|
Future<Message> updateMessage(
|
||||||
int id, {
|
int id, {
|
||||||
Object? body = notSpecified,
|
Object? body = notSpecified,
|
||||||
Object? mediaUrl = notSpecified,
|
|
||||||
Object? mediaType = notSpecified,
|
|
||||||
bool? isMedia,
|
|
||||||
bool? received,
|
bool? received,
|
||||||
bool? displayed,
|
bool? displayed,
|
||||||
bool? acked,
|
bool? acked,
|
||||||
|
Object? fileMetadata = notSpecified,
|
||||||
Object? errorType = notSpecified,
|
Object? errorType = notSpecified,
|
||||||
Object? warningType = notSpecified,
|
Object? warningType = notSpecified,
|
||||||
bool? isFileUploadNotification,
|
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? isUploading,
|
||||||
bool? isDownloading,
|
bool? isDownloading,
|
||||||
Object? originId = notSpecified,
|
Object? originId = notSpecified,
|
||||||
Object? sid = notSpecified,
|
Object? sid = notSpecified,
|
||||||
Object? thumbnailData = notSpecified,
|
|
||||||
bool? isRetracted,
|
bool? isRetracted,
|
||||||
bool? isEdited,
|
bool? isEdited,
|
||||||
Object? reactions = notSpecified,
|
|
||||||
}) async {
|
}) async {
|
||||||
final msg = await GetIt.I.get<DatabaseService>().updateMessage(
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
id,
|
final m = <String, dynamic>{};
|
||||||
body: body,
|
|
||||||
mediaUrl: mediaUrl,
|
if (body != notSpecified) {
|
||||||
mediaType: mediaType,
|
m['body'] = body as String?;
|
||||||
received: received,
|
}
|
||||||
displayed: displayed,
|
if (received != null) {
|
||||||
acked: acked,
|
m['received'] = boolToInt(received);
|
||||||
errorType: errorType,
|
}
|
||||||
warningType: warningType,
|
if (displayed != null) {
|
||||||
isFileUploadNotification: isFileUploadNotification,
|
m['displayed'] = boolToInt(displayed);
|
||||||
srcUrl: srcUrl,
|
}
|
||||||
key: key,
|
if (acked != null) {
|
||||||
iv: iv,
|
m['acked'] = boolToInt(acked);
|
||||||
encryptionScheme: encryptionScheme,
|
}
|
||||||
mediaWidth: mediaWidth,
|
if (errorType != notSpecified) {
|
||||||
mediaHeight: mediaHeight,
|
m['errorType'] = errorType as int?;
|
||||||
mediaSize: mediaSize,
|
}
|
||||||
isUploading: isUploading,
|
if (warningType != notSpecified) {
|
||||||
isDownloading: isDownloading,
|
m['warningType'] = warningType as int?;
|
||||||
originId: originId,
|
}
|
||||||
sid: sid,
|
if (isFileUploadNotification != null) {
|
||||||
isRetracted: isRetracted,
|
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
|
||||||
isMedia: isMedia,
|
}
|
||||||
thumbnailData: thumbnailData,
|
if (isDownloading != null) {
|
||||||
isEdited: isEdited,
|
m['isDownloading'] = boolToInt(isDownloading);
|
||||||
reactions: reactions,
|
}
|
||||||
);
|
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(() {
|
await _cacheLock.synchronized(() {
|
||||||
final page = _messageCache.getValue(msg.conversationJid);
|
final page = _messageCache.getValue(msg.conversationJid);
|
||||||
@@ -256,7 +533,6 @@ class MessageService {
|
|||||||
/// Helper function that manages everything related to retracting a message. It
|
/// 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
|
/// - 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
|
/// - 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
|
/// - Update the UI
|
||||||
///
|
///
|
||||||
/// [conversationJid] is the bare JID of the conversation this message belongs to.
|
/// [conversationJid] is the bare JID of the conversation this message belongs to.
|
||||||
@@ -271,10 +547,10 @@ class MessageService {
|
|||||||
String bareSender,
|
String bareSender,
|
||||||
bool selfRetract,
|
bool selfRetract,
|
||||||
) async {
|
) async {
|
||||||
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
|
final msg = await getMessageByXmppId(
|
||||||
originId,
|
originId,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
_log.finest(
|
_log.finest(
|
||||||
@@ -294,24 +570,13 @@ class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isMedia = msg.isMedia;
|
final isMedia = msg.isMedia;
|
||||||
final mediaUrl = msg.mediaUrl;
|
|
||||||
final retractedMessage = await updateMessage(
|
final retractedMessage = await updateMessage(
|
||||||
msg.id,
|
msg.id,
|
||||||
isMedia: false,
|
|
||||||
mediaUrl: null,
|
|
||||||
mediaType: null,
|
|
||||||
warningType: null,
|
warningType: null,
|
||||||
errorType: null,
|
errorType: null,
|
||||||
srcUrl: null,
|
|
||||||
key: null,
|
|
||||||
iv: null,
|
|
||||||
encryptionScheme: null,
|
|
||||||
mediaWidth: null,
|
|
||||||
mediaHeight: null,
|
|
||||||
mediaSize: null,
|
|
||||||
isRetracted: true,
|
isRetracted: true,
|
||||||
thumbnailData: null,
|
|
||||||
body: '',
|
body: '',
|
||||||
|
fileMetadata: null,
|
||||||
);
|
);
|
||||||
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
||||||
|
|
||||||
@@ -319,40 +584,23 @@ class MessageService {
|
|||||||
final conversation = await cs.getConversationByJid(conversationJid);
|
final conversation = await cs.getConversationByJid(conversationJid);
|
||||||
if (conversation != null) {
|
if (conversation != null) {
|
||||||
if (conversation.lastMessage?.id == msg.id) {
|
if (conversation.lastMessage?.id == msg.id) {
|
||||||
var newConversation = conversation.copyWith(
|
final newConversation = conversation.copyWith(
|
||||||
lastMessage: retractedMessage,
|
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);
|
cs.setConversation(newConversation);
|
||||||
sendEvent(
|
sendEvent(
|
||||||
ConversationUpdatedEvent(
|
ConversationUpdatedEvent(
|
||||||
conversation: newConversation,
|
conversation: newConversation,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMedia) {
|
||||||
|
// Remove the file
|
||||||
|
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
|
||||||
|
msg.fileMetadata!,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.warning(
|
_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) {
|
if (m.stickerPackId != null) {
|
||||||
body = t.messages.sticker;
|
body = t.messages.sticker;
|
||||||
} else if (m.isMedia) {
|
} else if (m.isMedia) {
|
||||||
body = mimeTypeToEmoji(m.mediaType);
|
body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
|
||||||
} else {
|
} else {
|
||||||
body = m.body;
|
body = m.body;
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ class NotificationsService {
|
|||||||
? NotificationLayout.BigPicture
|
? NotificationLayout.BigPicture
|
||||||
: NotificationLayout.Messaging,
|
: NotificationLayout.Messaging,
|
||||||
category: NotificationCategory.Message,
|
category: NotificationCategory.Message,
|
||||||
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null,
|
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
|
||||||
payload: <String, String>{
|
payload: <String, String>{
|
||||||
'conversationJid': c.jid,
|
'conversationJid': c.jid,
|
||||||
'sid': m.sid,
|
'sid': m.sid,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
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/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||||
import 'package:moxxyv2/service/omemo/implementations.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/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class OmemoDoubleRatchetWrapper {
|
class OmemoDoubleRatchetWrapper {
|
||||||
@@ -40,21 +44,19 @@ class OmemoService {
|
|||||||
final done = await _lock.synchronized(() => _initialized);
|
final done = await _lock.synchronized(() => _initialized);
|
||||||
if (done) return;
|
if (done) return;
|
||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
final device = await _loadOmemoDevice(jid);
|
||||||
final device = await db.loadOmemoDevice(jid);
|
|
||||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||||
final deviceList = <String, List<int>>{};
|
final deviceList = <String, List<int>>{};
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||||
} else {
|
} else {
|
||||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||||
for (final ratchet
|
for (final ratchet in await _loadRatchets()) {
|
||||||
in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
|
||||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||||
ratchetMap[key] = ratchet.ratchet;
|
ratchetMap[key] = ratchet.ratchet;
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceList.addAll(await db.loadOmemoDeviceList());
|
deviceList.addAll(await _loadOmemoDeviceList());
|
||||||
}
|
}
|
||||||
|
|
||||||
final om = GetIt.I
|
final om = GetIt.I
|
||||||
@@ -82,18 +84,18 @@ class OmemoService {
|
|||||||
|
|
||||||
omemoManager.eventStream.listen((event) async {
|
omemoManager.eventStream.listen((event) async {
|
||||||
if (event is RatchetModifiedEvent) {
|
if (event is RatchetModifiedEvent) {
|
||||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
await _saveRatchet(
|
||||||
OmemoDoubleRatchetWrapper(
|
OmemoDoubleRatchetWrapper(
|
||||||
event.ratchet,
|
event.ratchet,
|
||||||
event.deviceId,
|
event.deviceId,
|
||||||
event.jid,
|
event.jid,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event.added) {
|
if (event.added) {
|
||||||
// Cache the fingerprint
|
// Cache the fingerprint
|
||||||
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||||
await GetIt.I.get<DatabaseService>().addFingerprintsToCache([
|
await _addFingerprintsToCache([
|
||||||
OmemoCacheTriple(
|
OmemoCacheTriple(
|
||||||
event.jid,
|
event.jid,
|
||||||
event.deviceId,
|
event.deviceId,
|
||||||
@@ -143,7 +145,6 @@ class OmemoService {
|
|||||||
DateTime.now().millisecondsSinceEpoch,
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
'',
|
'',
|
||||||
jid,
|
jid,
|
||||||
false,
|
|
||||||
'',
|
'',
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -171,7 +172,7 @@ class OmemoService {
|
|||||||
final oldId = await omemoManager.getDeviceId();
|
final oldId = await omemoManager.getDeviceId();
|
||||||
|
|
||||||
// Clear the database
|
// Clear the database
|
||||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
await _emptyOmemoSessionTables();
|
||||||
|
|
||||||
// Regenerate the identity in the background
|
// Regenerate the identity in the background
|
||||||
final device = await compute(generateNewIdentityImpl, jid);
|
final device = await compute(generateNewIdentityImpl, jid);
|
||||||
@@ -228,11 +229,11 @@ class OmemoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
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 {
|
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
|
/// 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 device = await omemoManager.getDevice();
|
||||||
|
|
||||||
final bundlesRaw = await dm.discoItemsQuery(
|
final bundlesRaw = await dm.discoItemsQuery(
|
||||||
bareJid.toString(),
|
bareJid,
|
||||||
node: moxxmpp.omemoBundlesXmlns,
|
node: moxxmpp.omemoBundlesXmlns,
|
||||||
);
|
);
|
||||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||||
@@ -305,7 +306,7 @@ class OmemoService {
|
|||||||
_fingerprintCache[bareJid] = map;
|
_fingerprintCache[bareJid] = map;
|
||||||
|
|
||||||
// Cache them in the database
|
// 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();
|
final bareJid = jid.toBare().toString();
|
||||||
if (!_fingerprintCache.containsKey(bareJid)) {
|
if (!_fingerprintCache.containsKey(bareJid)) {
|
||||||
// First try to load it from the database
|
// First try to load it from the database
|
||||||
final triples = await GetIt.I
|
final triples = await _getFingerprintsFromCache(bareJid);
|
||||||
.get<DatabaseService>()
|
|
||||||
.getFingerprintsFromCache(bareJid);
|
|
||||||
if (triples.isEmpty) {
|
if (triples.isEmpty) {
|
||||||
// We found no fingerprints in the database, so try to fetch them
|
// We found no fingerprints in the database, so try to fetch them
|
||||||
await _fetchFingerprintsAndCache(jid);
|
await _fetchFingerprintsAndCache(jid);
|
||||||
@@ -361,23 +360,22 @@ class OmemoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
await _saveTrustCache(
|
||||||
json['trust']! as Map<String, int>,
|
json['trust']! as Map<String, int>,
|
||||||
);
|
);
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
|
await _saveTrustEnablementList(
|
||||||
json['enable']! as Map<String, bool>,
|
json['enable']! as Map<String, bool>,
|
||||||
);
|
);
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
|
await _saveTrustDeviceList(
|
||||||
json['devices']! as Map<String, List<int>>,
|
json['devices']! as Map<String, List<int>>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
return MoxxyBTBVTrustManager(
|
return MoxxyBTBVTrustManager(
|
||||||
await db.loadTrustCache(),
|
await _loadTrustCache(),
|
||||||
await db.loadTrustEnablementList(),
|
await _loadTrustEnablementList(),
|
||||||
await db.loadTrustDeviceList(),
|
await _loadTrustDeviceList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,4 +453,301 @@ class OmemoService {
|
|||||||
omemoManager.onNewConnection();
|
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:get_it/get_it.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
|
||||||
class PreferencesService {
|
class PreferencesService {
|
||||||
PreferencesState? _preferences;
|
PreferencesState? _preferences;
|
||||||
|
|
||||||
Future<void> _loadPreferences() async {
|
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 {
|
Future<PreferencesState> getPreferences() async {
|
||||||
@@ -21,6 +46,38 @@ class PreferencesService {
|
|||||||
if (_preferences == null) await _loadPreferences();
|
if (_preferences == null) await _loadPreferences();
|
||||||
|
|
||||||
_preferences = func(_preferences!);
|
_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:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/contacts.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/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/service/not_specified.dart';
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/subscription.dart';
|
import 'package:moxxyv2/service/subscription.dart';
|
||||||
@@ -42,19 +44,28 @@ class RosterService {
|
|||||||
String? contactDisplayName, {
|
String? contactDisplayName, {
|
||||||
List<String> groups = const [],
|
List<String> groups = const [],
|
||||||
}) async {
|
}) async {
|
||||||
final item = await GetIt.I.get<DatabaseService>().addRosterItemFromData(
|
// TODO(PapaTutuWawa): Handle groups
|
||||||
avatarUrl,
|
final i = RosterItem(
|
||||||
avatarHash,
|
-1,
|
||||||
jid,
|
avatarUrl,
|
||||||
title,
|
avatarHash,
|
||||||
subscription,
|
jid,
|
||||||
ask,
|
title,
|
||||||
pseudoRosterItem,
|
subscription,
|
||||||
contactId,
|
ask,
|
||||||
contactAvatarPath,
|
pseudoRosterItem,
|
||||||
contactDisplayName,
|
<String>[],
|
||||||
groups: groups,
|
contactId: contactId,
|
||||||
);
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = i.copyWith(
|
||||||
|
id: await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.insert(rosterTable, i.toDatabaseJson()),
|
||||||
|
);
|
||||||
|
|
||||||
// Update the cache
|
// Update the cache
|
||||||
_rosterCache![item.jid] = item;
|
_rosterCache![item.jid] = item;
|
||||||
@@ -76,19 +87,49 @@ class RosterService {
|
|||||||
Object? contactAvatarPath = notSpecified,
|
Object? contactAvatarPath = notSpecified,
|
||||||
Object? contactDisplayName = notSpecified,
|
Object? contactDisplayName = notSpecified,
|
||||||
}) async {
|
}) async {
|
||||||
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
final i = <String, dynamic>{};
|
||||||
id,
|
|
||||||
avatarUrl: avatarUrl,
|
if (avatarUrl != null) {
|
||||||
avatarHash: avatarHash,
|
i['avatarUrl'] = avatarUrl;
|
||||||
title: title,
|
}
|
||||||
subscription: subscription,
|
if (avatarHash != null) {
|
||||||
ask: ask,
|
i['avatarHash'] = avatarHash;
|
||||||
pseudoRosterItem: pseudoRosterItem,
|
}
|
||||||
groups: groups,
|
if (title != null) {
|
||||||
contactId: contactId,
|
i['title'] = title;
|
||||||
contactAvatarPath: contactAvatarPath,
|
}
|
||||||
contactDisplayName: contactDisplayName,
|
/*
|
||||||
);
|
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
|
// Update cache
|
||||||
_rosterCache![newItem.jid] = newItem;
|
_rosterCache![newItem.jid] = newItem;
|
||||||
@@ -96,10 +137,14 @@ class RosterService {
|
|||||||
return newItem;
|
return newItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
/// Removes a roster item from the database and cache
|
||||||
Future<void> removeRosterItem(int id) async {
|
Future<void> removeRosterItem(int id) async {
|
||||||
// NOTE: This call ensures that _rosterCache != null
|
// 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');
|
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||||
|
|
||||||
/// Update cache
|
/// Update cache
|
||||||
@@ -136,14 +181,16 @@ class RosterService {
|
|||||||
/// Load the roster from the database. This function is guarded against loading the
|
/// Load the roster from the database. This function is guarded against loading the
|
||||||
/// roster multiple times and thus creating too many "RosterDiff" actions.
|
/// roster multiple times and thus creating too many "RosterDiff" actions.
|
||||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
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>{};
|
_rosterCache = <String, RosterItem>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
_rosterCache![item.jid] = item;
|
_rosterCache![item.jid] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to add an item to the roster by first performing the roster set
|
/// 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.getProfilePicturePathForJid(jid),
|
||||||
await css.getContactDisplayName(contactId),
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await GetIt.I
|
final result = await GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getRosterManager()!
|
.getRosterManager()!
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'package:moxxyv2/service/conversation.dart';
|
|||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/events.dart';
|
import 'package:moxxyv2/service/events.dart';
|
||||||
|
import 'package:moxxyv2/service/files.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/language.dart';
|
import 'package:moxxyv2/service/language.dart';
|
||||||
import 'package:moxxyv2/service/message.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/notifications.dart';
|
||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/stickers.dart';
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
import 'package:moxxyv2/service/subscription.dart';
|
import 'package:moxxyv2/service/subscription.dart';
|
||||||
@@ -176,6 +178,8 @@ Future<void> entrypoint() async {
|
|||||||
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
||||||
SubscriptionRequestService(),
|
SubscriptionRequestService(),
|
||||||
);
|
);
|
||||||
|
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||||
|
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||||
final xmpp = XmppService();
|
final xmpp = XmppService();
|
||||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
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/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/client.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.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/preferences.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.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.dart';
|
||||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
@@ -26,31 +30,69 @@ class StickersService {
|
|||||||
Future<StickerPack?> getStickerPackById(String id) async {
|
Future<StickerPack?> getStickerPackById(String id) async {
|
||||||
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
|
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
|
||||||
|
|
||||||
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id);
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
if (pack == null) return null;
|
final rawPack = await db.query(
|
||||||
|
stickerPacksTable,
|
||||||
_stickerPacks[id] = pack;
|
where: 'id = ?',
|
||||||
return _stickerPacks[id];
|
whereArgs: [id],
|
||||||
}
|
limit: 1,
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
Future<List<StickerPack>> getStickerPacks() async {
|
||||||
if (_stickerPacks.isEmpty) {
|
if (_stickerPacks.isEmpty) {
|
||||||
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks();
|
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
for (final pack in packs) {
|
stickerPacksTable,
|
||||||
_stickerPacks[pack.id] = pack;
|
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();
|
return _stickerPacks.values.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,21 +101,27 @@ class StickersService {
|
|||||||
assert(pack != null, 'The sticker pack must exist');
|
assert(pack != null, 'The sticker pack must exist');
|
||||||
|
|
||||||
// Delete the files
|
// Delete the files
|
||||||
final stickerPackPath = await getStickerPackPath(
|
for (final sticker in pack!.stickers) {
|
||||||
pack!.hashAlgorithm,
|
if (sticker.fileMetadata.path == null) {
|
||||||
pack.hashValue,
|
continue;
|
||||||
);
|
}
|
||||||
final stickerPackDir = Directory(stickerPackPath);
|
|
||||||
if (stickerPackDir.existsSync()) {
|
await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||||
unawaited(
|
sticker.fileMetadata.id,
|
||||||
stickerPackDir.delete(
|
path: null,
|
||||||
recursive: true,
|
);
|
||||||
),
|
final file = File(sticker.fileMetadata.path!);
|
||||||
);
|
if (file.existsSync()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from the database
|
// 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
|
// Remove from the cache
|
||||||
_stickerPacks.remove(id);
|
_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(
|
Future<void> importFromPubSubWithEvent(
|
||||||
moxxmpp.JID jid,
|
moxxmpp.JID jid,
|
||||||
String stickerPackId,
|
String stickerPackId,
|
||||||
@@ -158,36 +196,96 @@ class StickersService {
|
|||||||
return installFromPubSub(stickerPackRaw);
|
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 {
|
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
|
||||||
assert(!remotePack.local, 'Sticker pack must be remote');
|
assert(!remotePack.local, 'Sticker pack must be remote');
|
||||||
|
|
||||||
final stickerPackPath = await _getStickerPackPath(
|
|
||||||
remotePack.hashAlgorithm,
|
|
||||||
remotePack.hashValue,
|
|
||||||
);
|
|
||||||
|
|
||||||
var success = true;
|
var success = true;
|
||||||
final stickers = List<Sticker>.from(remotePack.stickers);
|
final stickers = List<Sticker>.from(remotePack.stickers);
|
||||||
for (var i = 0; i < stickers.length; i++) {
|
for (var i = 0; i < stickers.length; i++) {
|
||||||
final sticker = stickers[i];
|
final sticker = stickers[i];
|
||||||
final stickerPath = p.join(
|
final stickerPath = await computeCachedPathForFile(
|
||||||
stickerPackPath,
|
sticker.fileMetadata.filename,
|
||||||
sticker.hashes.values.first,
|
sticker.fileMetadata.plaintextHashes,
|
||||||
);
|
|
||||||
final downloadStatusCode = await downloadFile(
|
|
||||||
Uri.parse(sticker.urlSources.first),
|
|
||||||
stickerPath,
|
|
||||||
(_, __) {},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isRequestOkay(downloadStatusCode)) {
|
// Get file metadata
|
||||||
_log.severe('Request not okay: $downloadStatusCode');
|
final fileMetadataRaw =
|
||||||
success = false;
|
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||||
break;
|
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.fileMetadata.sourceUrls!.first),
|
||||||
|
stickerPath,
|
||||||
|
(_, __) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRequestOkay(downloadStatusCode)) {
|
||||||
|
_log.severe('Request not okay: $downloadStatusCode');
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stickers[i] = sticker.copyWith(
|
|
||||||
path: stickerPath,
|
stickers[i] = await _addStickerFromData(
|
||||||
hashKey: getStickerHashKey(sticker.hashes),
|
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
|
// Add the sticker pack to the database
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
await _addStickerPackFromData(remotePack);
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish but don't block
|
// Publish but don't block
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -225,7 +303,7 @@ class StickersService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return remotePack.copyWith(
|
return remotePack.copyWith(
|
||||||
stickers: stickersDb,
|
stickers: stickers,
|
||||||
local: true,
|
local: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -299,8 +377,6 @@ class StickersService {
|
|||||||
final stickerDir = Directory(stickerDirPath);
|
final stickerDir = Directory(stickerDirPath);
|
||||||
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
|
|
||||||
// Create the sticker pack first
|
// Create the sticker pack first
|
||||||
final stickerPack = StickerPack(
|
final stickerPack = StickerPack(
|
||||||
pack.hashValue,
|
pack.hashValue,
|
||||||
@@ -312,33 +388,65 @@ class StickersService {
|
|||||||
pack.restricted,
|
pack.restricted,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
await db.addStickerPackFromData(stickerPack);
|
await _addStickerPackFromData(stickerPack);
|
||||||
|
|
||||||
// Add all stickers
|
// Add all stickers
|
||||||
final stickers = List<Sticker>.empty(growable: true);
|
final stickers = List<Sticker>.empty(growable: true);
|
||||||
for (final sticker in pack.stickers) {
|
for (final sticker in pack.stickers) {
|
||||||
final filename = sticker.metadata.name!;
|
// Get the "path" to the sticker
|
||||||
final stickerFile = archive.findFile(filename)!;
|
final stickerPath = await computeCachedPathForFile(
|
||||||
final stickerPath = p.join(stickerDirPath, filename);
|
sticker.metadata.name!,
|
||||||
await File(stickerPath).writeAsBytes(
|
sticker.metadata.hashes,
|
||||||
stickerFile.content as List<int>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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(
|
stickers.add(
|
||||||
await db.addStickerFromData(
|
await _addStickerFromData(
|
||||||
sticker.metadata.mediaType!,
|
getStrongestHashFromMap(sticker.metadata.hashes) ??
|
||||||
sticker.metadata.desc!,
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
sticker.metadata.size!,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
sticker.metadata.hashes,
|
|
||||||
sticker.sources
|
|
||||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
|
||||||
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
|
|
||||||
.toList(),
|
|
||||||
stickerPath,
|
|
||||||
pack.hashValue,
|
pack.hashValue,
|
||||||
|
sticker.metadata.desc!,
|
||||||
sticker.suggests,
|
sticker.suggests,
|
||||||
|
fileMetadataRaw.fileMetadata,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.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/database.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class SubscriptionRequestService {
|
class SubscriptionRequestService {
|
||||||
@@ -8,15 +10,18 @@ class SubscriptionRequestService {
|
|||||||
|
|
||||||
final Lock _lock = Lock();
|
final Lock _lock = Lock();
|
||||||
|
|
||||||
DatabaseService get _db => GetIt.I.get<DatabaseService>();
|
|
||||||
|
|
||||||
/// Only load data from the database into
|
/// Only load data from the database into
|
||||||
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
||||||
/// been loaded.
|
/// been loaded.
|
||||||
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
||||||
await _lock.synchronized(() async {
|
await _lock.synchronized(() async {
|
||||||
_subscriptionRequests ??= List<String>.from(
|
_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)) {
|
if (!_subscriptionRequests!.contains(jid)) {
|
||||||
_subscriptionRequests!.add(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 {
|
await _lock.synchronized(() async {
|
||||||
if (_subscriptionRequests!.contains(jid)) {
|
if (_subscriptionRequests!.contains(jid)) {
|
||||||
_subscriptionRequests!.remove(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/connectivity_watcher.dart';
|
||||||
import 'package:moxxyv2/service/contacts.dart';
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.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/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/stickers.dart';
|
|
||||||
import 'package:moxxyv2/service/subscription.dart';
|
import 'package:moxxyv2/service/subscription.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.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/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.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/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
@@ -47,6 +47,7 @@ class XmppService {
|
|||||||
XmppService() {
|
XmppService() {
|
||||||
_eventHandler.addMatchers([
|
_eventHandler.addMatchers([
|
||||||
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
|
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
|
||||||
|
EventTypeMatcher<StreamNegotiationsDoneEvent>(_onStreamNegotiationsDone),
|
||||||
EventTypeMatcher<ResourceBoundEvent>(_onResourceBound),
|
EventTypeMatcher<ResourceBoundEvent>(_onResourceBound),
|
||||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(
|
EventTypeMatcher<SubscriptionRequestReceivedEvent>(
|
||||||
_onSubscriptionRequestReceived,
|
_onSubscriptionRequestReceived,
|
||||||
@@ -222,7 +223,6 @@ class XmppService {
|
|||||||
timestamp,
|
timestamp,
|
||||||
conn.connectionSettings.jid.toString(),
|
conn.connectionSettings.jid.toString(),
|
||||||
recipient,
|
recipient,
|
||||||
sticker != null,
|
|
||||||
sid,
|
sid,
|
||||||
false,
|
false,
|
||||||
c.type == ConversationType.note ? true : c.encrypted,
|
c.type == ConversationType.note ? true : c.encrypted,
|
||||||
@@ -231,9 +231,7 @@ class XmppService {
|
|||||||
originId: originId,
|
originId: originId,
|
||||||
quoteId: quotedMessage?.sid,
|
quoteId: quotedMessage?.sid,
|
||||||
stickerPackId: sticker?.stickerPackId,
|
stickerPackId: sticker?.stickerPackId,
|
||||||
stickerHashKey: sticker?.hashKey,
|
fileMetadata: sticker?.fileMetadata,
|
||||||
srcUrl: sticker?.urlSources.first,
|
|
||||||
mediaType: sticker?.mediaType,
|
|
||||||
received: c.type == ConversationType.note ? true : false,
|
received: c.type == ConversationType.note ? true : false,
|
||||||
displayed: c.type == ConversationType.note ? true : false,
|
displayed: c.type == ConversationType.note ? true : false,
|
||||||
);
|
);
|
||||||
@@ -260,6 +258,7 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (conversation?.type == ConversationType.chat) {
|
if (conversation?.type == ConversationType.chat) {
|
||||||
|
final moxxmppSticker = sticker?.toMoxxmpp();
|
||||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
MessageDetails(
|
MessageDetails(
|
||||||
to: recipient,
|
to: recipient,
|
||||||
@@ -273,23 +272,12 @@ class XmppService {
|
|||||||
chatState: chatState,
|
chatState: chatState,
|
||||||
shouldEncrypt: conversation!.encrypted,
|
shouldEncrypt: conversation!.encrypted,
|
||||||
stickerPackId: sticker?.stickerPackId,
|
stickerPackId: sticker?.stickerPackId,
|
||||||
sfs: sticker == null
|
sfs: moxxmppSticker != null
|
||||||
? null
|
? StatelessFileSharingData(
|
||||||
: StatelessFileSharingData(
|
moxxmppSticker.metadata,
|
||||||
FileMetadataData(
|
moxxmppSticker.sources,
|
||||||
mediaType: sticker.mediaType,
|
)
|
||||||
width: sticker.width,
|
: null,
|
||||||
height: sticker.height,
|
|
||||||
desc: sticker.desc,
|
|
||||||
size: sticker.size,
|
|
||||||
thumbnails: [],
|
|
||||||
hashes: sticker.hashes,
|
|
||||||
),
|
|
||||||
sticker.urlSources
|
|
||||||
// ignore: unnecessary_lambdas
|
|
||||||
.map((s) => StatelessFileSharingUrlSource(s))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
setOOBFallbackBody: sticker != null ? false : true,
|
setOOBFallbackBody: sticker != null ? false : true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -301,50 +289,67 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaFileLocation? _getMessageSrcUrl(MessageEvent event) {
|
MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
|
||||||
if (event.sfs != null) {
|
if (event.sfs?.sources.isNotEmpty ?? false) {
|
||||||
final source = firstWhereOrNull(
|
// final source = firstWhereOrNull(
|
||||||
event.sfs!.sources,
|
// event.sfs!.sources,
|
||||||
(StatelessFileSharingSource source) {
|
// (StatelessFileSharingSource source) {
|
||||||
return source is StatelessFileSharingUrlSource ||
|
// return source is StatelessFileSharingUrlSource ||
|
||||||
source is StatelessFileSharingEncryptedSource;
|
// source is StatelessFileSharingEncryptedSource;
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
|
|
||||||
final name = event.sfs?.metadata.name;
|
final hasUrlSource = firstWhereOrNull(
|
||||||
if (source is StatelessFileSharingUrlSource) {
|
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(
|
return MediaFileLocation(
|
||||||
source.url,
|
sources,
|
||||||
name != null ? escapeFilename(name) : filenameFromUrl(source.url),
|
name != null ? escapeFilename(name) : filenameFromUrl(sources.first),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
event.sfs?.metadata.hashes,
|
event.sfs!.metadata.hashes,
|
||||||
null,
|
null,
|
||||||
|
event.sfs!.metadata.size,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final esource = source! as StatelessFileSharingEncryptedSource;
|
final encryptedSource = firstWhereOrNull(
|
||||||
|
event.sfs!.sources,
|
||||||
|
(src) => src is StatelessFileSharingEncryptedSource,
|
||||||
|
)! as StatelessFileSharingEncryptedSource;
|
||||||
|
|
||||||
return MediaFileLocation(
|
return MediaFileLocation(
|
||||||
esource.source.url,
|
[encryptedSource.source.url],
|
||||||
name != null
|
name != null
|
||||||
? escapeFilename(name)
|
? escapeFilename(name)
|
||||||
: filenameFromUrl(esource.source.url),
|
: filenameFromUrl(encryptedSource.source.url),
|
||||||
esource.encryption.toNamespace(),
|
encryptedSource.encryption.toNamespace(),
|
||||||
esource.key,
|
encryptedSource.key,
|
||||||
esource.iv,
|
encryptedSource.iv,
|
||||||
event.sfs?.metadata.hashes,
|
event.sfs?.metadata.hashes,
|
||||||
esource.hashes,
|
encryptedSource.hashes,
|
||||||
|
event.sfs!.metadata.size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (event.oob != null) {
|
} else if (event.oob != null) {
|
||||||
return MediaFileLocation(
|
return MediaFileLocation(
|
||||||
event.oob!.url!,
|
[event.oob!.url!],
|
||||||
filenameFromUrl(event.oob!.url!),
|
filenameFromUrl(event.oob!.url!),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,22 +360,25 @@ class XmppService {
|
|||||||
final result = await GetIt.I
|
final result = await GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getDiscoManager()!
|
.getDiscoManager()!
|
||||||
.discoInfoQuery(event.fromJid.toString());
|
.discoInfoQuery(event.fromJid);
|
||||||
if (result.isType<DiscoError>()) return;
|
if (result.isType<DiscoError>()) return;
|
||||||
|
|
||||||
final info = result.get<DiscoInfo>();
|
final info = result.get<DiscoInfo>();
|
||||||
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
|
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
|
||||||
unawaited(
|
unawaited(
|
||||||
GetIt.I.get<XmppConnection>().sendStanza(
|
GetIt.I.get<XmppConnection>().sendStanza(
|
||||||
Stanza.message(
|
StanzaDetails(
|
||||||
to: event.fromJid.toBare().toString(),
|
Stanza.message(
|
||||||
type: event.type,
|
to: event.fromJid.toBare().toString(),
|
||||||
children: [
|
type: event.type,
|
||||||
makeChatMarker(
|
children: [
|
||||||
'received',
|
makeChatMarker(
|
||||||
event.stanzaId.originId ?? event.sid,
|
'received',
|
||||||
)
|
event.originId ?? event.sid,
|
||||||
],
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -378,14 +386,17 @@ class XmppService {
|
|||||||
info.features.contains(deliveryXmlns)) {
|
info.features.contains(deliveryXmlns)) {
|
||||||
unawaited(
|
unawaited(
|
||||||
GetIt.I.get<XmppConnection>().sendStanza(
|
GetIt.I.get<XmppConnection>().sendStanza(
|
||||||
Stanza.message(
|
StanzaDetails(
|
||||||
to: event.fromJid.toBare().toString(),
|
Stanza.message(
|
||||||
type: event.type,
|
to: event.fromJid.toBare().toString(),
|
||||||
children: [
|
type: event.type,
|
||||||
makeMessageDeliveryResponse(
|
children: [
|
||||||
event.stanzaId.originId ?? event.sid,
|
makeMessageDeliveryResponse(
|
||||||
)
|
event.originId ?? event.sid,
|
||||||
],
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -401,12 +412,15 @@ class XmppService {
|
|||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
GetIt.I.get<XmppConnection>().sendStanza(
|
GetIt.I.get<XmppConnection>().sendStanza(
|
||||||
Stanza.message(
|
StanzaDetails(
|
||||||
to: to,
|
Stanza.message(
|
||||||
type: 'chat',
|
to: to,
|
||||||
children: [
|
type: 'chat',
|
||||||
makeChatMarker('displayed', sid),
|
children: [
|
||||||
],
|
makeChatMarker('displayed', sid),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -442,12 +456,15 @@ class XmppService {
|
|||||||
_loginTriggeredFromUI = triggeredFromUI;
|
_loginTriggeredFromUI = triggeredFromUI;
|
||||||
conn
|
conn
|
||||||
..connectionSettings = settings
|
..connectionSettings = settings
|
||||||
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource;
|
..getNegotiatorById<StreamManagementNegotiator>(
|
||||||
|
streamManagementNegotiator,
|
||||||
|
)!
|
||||||
|
.resource = lastResource;
|
||||||
unawaited(
|
unawaited(
|
||||||
conn.connect(
|
conn.connect(
|
||||||
waitForConnection: true,
|
waitForConnection: true,
|
||||||
shouldReconnect: true,
|
shouldReconnect: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
installEventHandlers();
|
installEventHandlers();
|
||||||
}
|
}
|
||||||
@@ -463,40 +480,15 @@ class XmppService {
|
|||||||
_loginTriggeredFromUI = triggeredFromUI;
|
_loginTriggeredFromUI = triggeredFromUI;
|
||||||
conn
|
conn
|
||||||
..connectionSettings = settings
|
..connectionSettings = settings
|
||||||
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource;
|
..getNegotiatorById<StreamManagementNegotiator>(
|
||||||
|
streamManagementNegotiator,
|
||||||
|
)!
|
||||||
|
.resource = lastResource;
|
||||||
installEventHandlers();
|
installEventHandlers();
|
||||||
return conn.connect(
|
return conn.connect(
|
||||||
waitForConnection: true,
|
waitForConnection: true,
|
||||||
waitUntilLogin: true,
|
waitUntilLogin: true,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
Future<void> sendFiles(List<String> paths, List<String> recipients) async {
|
||||||
@@ -516,49 +508,67 @@ class XmppService {
|
|||||||
final encrypt = <String, bool>{};
|
final encrypt = <String, bool>{};
|
||||||
// Recipient -> Last message Id
|
// Recipient -> Last message Id
|
||||||
final lastMessages = <String, Message>{};
|
final lastMessages = <String, Message>{};
|
||||||
|
// Path -> Metadata Id
|
||||||
|
final metadataMap = <String, String>{};
|
||||||
|
|
||||||
// Create the messages and shared media entries
|
// Create the messages and shared media entries
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
for (final path in paths) {
|
for (final path in paths) {
|
||||||
final pathMime = lookupMimeType(path);
|
final pathMime = lookupMimeType(path);
|
||||||
|
// TODO(Unknown): Do the same for videos
|
||||||
|
if (pathMime != null && pathMime.startsWith('image/')) {
|
||||||
|
final imageSize = await getImageSizeFromPath(path);
|
||||||
|
if (imageSize != null) {
|
||||||
|
dimensions[path] = Size(
|
||||||
|
imageSize.width,
|
||||||
|
imageSize.height,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.warning('Failed to get image dimensions for $path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
for (final recipient in recipients) {
|
||||||
final conversation = await cs.getConversationByJid(recipient);
|
final conversation = await cs.getConversationByJid(recipient);
|
||||||
encrypt[recipient] =
|
encrypt[recipient] =
|
||||||
conversation?.encrypted ?? prefs.enableOmemoByDefault;
|
conversation?.encrypted ?? prefs.enableOmemoByDefault;
|
||||||
|
|
||||||
// TODO(Unknown): Do the same for videos
|
|
||||||
if (pathMime != null && pathMime.startsWith('image/')) {
|
|
||||||
final imageSize = await getImageSizeFromPath(path);
|
|
||||||
if (imageSize != null) {
|
|
||||||
dimensions[path] = Size(
|
|
||||||
imageSize.width,
|
|
||||||
imageSize.height,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_log.warning('Failed to get image dimensions for $path');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final msg = await ms.addMessageFromData(
|
final msg = await ms.addMessageFromData(
|
||||||
'',
|
'',
|
||||||
DateTime.now().millisecondsSinceEpoch,
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
conn.connectionSettings.jid.toString(),
|
conn.connectionSettings.jid.toString(),
|
||||||
recipient,
|
recipient,
|
||||||
true,
|
|
||||||
conn.generateId(),
|
conn.generateId(),
|
||||||
false,
|
|
||||||
conversation?.type == ConversationType.note
|
conversation?.type == ConversationType.note
|
||||||
? true
|
? true
|
||||||
: encrypt[recipient]!,
|
: encrypt[recipient]!,
|
||||||
// TODO(Unknown): Maybe make this depend on some setting
|
// TODO(Unknown): Maybe make this depend on some setting
|
||||||
false,
|
false,
|
||||||
mediaUrl: path,
|
false,
|
||||||
mediaType: pathMime,
|
fileMetadata: metadata,
|
||||||
originId: conn.generateId(),
|
originId: conn.generateId(),
|
||||||
mediaWidth: dimensions[path]?.width.toInt(),
|
|
||||||
mediaHeight: dimensions[path]?.height.toInt(),
|
|
||||||
filename: pathlib.basename(path),
|
|
||||||
isUploading:
|
isUploading:
|
||||||
conversation?.type != ConversationType.note ? true : false,
|
conversation?.type != ConversationType.note ? true : false,
|
||||||
received: 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>();
|
final rs = GetIt.I.get<RosterService>();
|
||||||
|
|
||||||
for (final recipient in recipients) {
|
for (final recipient in recipients) {
|
||||||
await cs.createOrUpdateConversation(
|
await cs.createOrUpdateConversation(
|
||||||
recipient,
|
recipient,
|
||||||
@@ -590,7 +596,7 @@ class XmppService {
|
|||||||
// Create
|
// Create
|
||||||
final rosterItem = await rs.getRosterItemByJid(recipient);
|
final rosterItem = await rs.getRosterItemByJid(recipient);
|
||||||
final contactId = await css.getContactIdForJid(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?
|
// TODO(Unknown): Should we use the JID parser?
|
||||||
rosterItem?.title ?? recipient.split('@').first,
|
rosterItem?.title ?? recipient.split('@').first,
|
||||||
lastMessages[recipient],
|
lastMessages[recipient],
|
||||||
@@ -602,22 +608,11 @@ class XmppService {
|
|||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
prefs.enableOmemoByDefault,
|
prefs.enableOmemoByDefault,
|
||||||
paths.length,
|
|
||||||
contactId,
|
contactId,
|
||||||
await css.getProfilePicturePathForJid(recipient),
|
await css.getProfilePicturePathForJid(recipient),
|
||||||
await css.getContactDisplayName(contactId),
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
final sharedMedia = await _createSharedMedia(
|
|
||||||
messages,
|
|
||||||
paths,
|
|
||||||
recipient,
|
|
||||||
newConversation.jid,
|
|
||||||
);
|
|
||||||
newConversation = newConversation.copyWith(
|
|
||||||
sharedMedia: sharedMedia.sublist(0, 8),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update the cache
|
// Update the cache
|
||||||
cs.setConversation(newConversation);
|
cs.setConversation(newConversation);
|
||||||
|
|
||||||
@@ -628,28 +623,11 @@ class XmppService {
|
|||||||
},
|
},
|
||||||
update: (c) async {
|
update: (c) async {
|
||||||
// Update
|
// Update
|
||||||
var newConversation = await cs.updateConversation(
|
final newConversation = await cs.updateConversation(
|
||||||
c.jid,
|
c.jid,
|
||||||
lastMessage: lastMessages[recipient],
|
lastMessage: lastMessages[recipient],
|
||||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
open: true,
|
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
|
// Update the cache
|
||||||
@@ -710,6 +688,7 @@ class XmppService {
|
|||||||
pathMime,
|
pathMime,
|
||||||
encrypt,
|
encrypt,
|
||||||
messages[path]!,
|
messages[path]!,
|
||||||
|
metadataMap[path]!,
|
||||||
thumbnails[path] ?? [],
|
thumbnails[path] ?? [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -763,6 +742,74 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onStreamNegotiationsDone(
|
||||||
|
StreamNegotiationsDoneEvent event, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
final connection = GetIt.I.get<XmppConnection>();
|
||||||
|
|
||||||
|
// TODO(Unknown): Maybe have something better
|
||||||
|
final settings = connection.connectionSettings;
|
||||||
|
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
||||||
|
(state) => state.copyWith(
|
||||||
|
jid: settings.jid.toString(),
|
||||||
|
password: settings.password,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.finest('Connection connected. Is resumed? ${event.resumed}');
|
||||||
|
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||||
|
|
||||||
|
if (!event.resumed) {
|
||||||
|
// Reset the blocking service's cache
|
||||||
|
GetIt.I.get<BlocklistService>().onNewConnection();
|
||||||
|
|
||||||
|
// Reset the OMEMO cache
|
||||||
|
GetIt.I.get<OmemoService>().onNewConnection();
|
||||||
|
|
||||||
|
// Enable carbons
|
||||||
|
final carbonsResult = await connection
|
||||||
|
.getManagerById<CarbonsManager>(carbonsManager)!
|
||||||
|
.enableCarbons();
|
||||||
|
if (!carbonsResult) {
|
||||||
|
_log.warning('Failed to enable carbons');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In section 5 of XEP-0198 it says that a client should not request the roster
|
||||||
|
// in case of a stream resumption.
|
||||||
|
await connection
|
||||||
|
.getManagerById<RosterManager>(rosterManager)!
|
||||||
|
.requestRoster();
|
||||||
|
|
||||||
|
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
|
||||||
|
final roster = await GetIt.I.get<RosterService>().getRoster();
|
||||||
|
for (final item in roster) {
|
||||||
|
await GetIt.I
|
||||||
|
.get<AvatarService>()
|
||||||
|
.fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetIt.I.get<BlocklistService>().getBlocklist();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we display our own avatar correctly.
|
||||||
|
// Note that this only requests the avatar if its hash differs from the locally cached avatar's.
|
||||||
|
// TODO(Unknown): Maybe don't do this on mobile Internet
|
||||||
|
unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
|
||||||
|
|
||||||
|
if (_loginTriggeredFromUI) {
|
||||||
|
// TODO(Unknown): Trigger another event so the UI can see this aswell
|
||||||
|
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
||||||
|
(state) => state.copyWith(
|
||||||
|
jid: connection.connectionSettings.jid.toString(),
|
||||||
|
displayName: connection.connectionSettings.jid.local,
|
||||||
|
avatarUrl: '',
|
||||||
|
avatarHash: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onConnectionStateChanged(
|
Future<void> _onConnectionStateChanged(
|
||||||
ConnectionStateChangedEvent event, {
|
ConnectionStateChangedEvent event, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
@@ -773,71 +820,6 @@ class XmppService {
|
|||||||
event.before,
|
event.before,
|
||||||
event.state,
|
event.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event.state == XmppConnectionState.connected) {
|
|
||||||
final connection = GetIt.I.get<XmppConnection>();
|
|
||||||
|
|
||||||
// TODO(Unknown): Maybe have something better
|
|
||||||
final settings = connection.connectionSettings;
|
|
||||||
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
|
||||||
(state) => state.copyWith(
|
|
||||||
jid: settings.jid.toString(),
|
|
||||||
password: settings.password,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_log.finest('Connection connected. Is resumed? ${event.resumed}');
|
|
||||||
unawaited(_initializeOmemoService(settings.jid.toString()));
|
|
||||||
|
|
||||||
if (!event.resumed) {
|
|
||||||
// Reset the blocking service's cache
|
|
||||||
GetIt.I.get<BlocklistService>().onNewConnection();
|
|
||||||
|
|
||||||
// Reset the OMEMO cache
|
|
||||||
GetIt.I.get<OmemoService>().onNewConnection();
|
|
||||||
|
|
||||||
// Enable carbons
|
|
||||||
final carbonsResult = await connection
|
|
||||||
.getManagerById<CarbonsManager>(carbonsManager)!
|
|
||||||
.enableCarbons();
|
|
||||||
if (!carbonsResult) {
|
|
||||||
_log.warning('Failed to enable carbons');
|
|
||||||
}
|
|
||||||
|
|
||||||
// In section 5 of XEP-0198 it says that a client should not request the roster
|
|
||||||
// in case of a stream resumption.
|
|
||||||
await connection
|
|
||||||
.getManagerById<RosterManager>(rosterManager)!
|
|
||||||
.requestRoster();
|
|
||||||
|
|
||||||
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
|
|
||||||
final roster = await GetIt.I.get<RosterService>().getRoster();
|
|
||||||
for (final item in roster) {
|
|
||||||
await GetIt.I
|
|
||||||
.get<AvatarService>()
|
|
||||||
.fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
await GetIt.I.get<BlocklistService>().getBlocklist();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure we display our own avatar correctly.
|
|
||||||
// Note that this only requests the avatar if its hash differs from the locally cached avatar's.
|
|
||||||
// TODO(Unknown): Maybe don't do this on mobile Internet
|
|
||||||
unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
|
|
||||||
|
|
||||||
if (_loginTriggeredFromUI) {
|
|
||||||
// TODO(Unknown): Trigger another event so the UI can see this aswell
|
|
||||||
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
|
||||||
(state) => state.copyWith(
|
|
||||||
jid: connection.connectionSettings.jid.toString(),
|
|
||||||
displayName: connection.connectionSettings.jid.local,
|
|
||||||
avatarUrl: '',
|
|
||||||
avatarHash: '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onResourceBound(
|
Future<void> _onResourceBound(
|
||||||
@@ -876,11 +858,10 @@ class XmppService {
|
|||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
_log.finest('Received delivery receipt from ${event.from}');
|
_log.finest('Received delivery receipt from ${event.from}');
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final sender = event.from.toBare().toString();
|
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) {
|
if (dbMsg == null) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
'Did not find the message with id ${event.id} in the database!',
|
'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 {
|
Future<void> _onChatMarker(ChatMarkerEvent event, {dynamic extra}) async {
|
||||||
_log.finest('Chat marker from ${event.from}');
|
_log.finest('Chat marker from ${event.from}');
|
||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final sender = event.from.toBare().toString();
|
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) {
|
if (dbMsg == null) {
|
||||||
_log.warning('Did not find the message in the database!');
|
_log.warning('Did not find the message in the database!');
|
||||||
return;
|
return;
|
||||||
@@ -1010,9 +990,8 @@ class XmppService {
|
|||||||
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
|
// 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.
|
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||||
return embeddedFile != null &&
|
return embeddedFile != null &&
|
||||||
Uri.parse(embeddedFile.url).scheme == 'https' &&
|
Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
|
||||||
implies(event.oob != null, event.body == event.oob?.url) &&
|
implies(event.oob != null, event.body == event.oob?.url);
|
||||||
event.stickerPackId == null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a message retraction given the MessageEvent [event].
|
/// Handle a message retraction given the MessageEvent [event].
|
||||||
@@ -1141,9 +1120,10 @@ class XmppService {
|
|||||||
) async {
|
) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
|
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
|
||||||
final msg = await ms.getMessageByStanzaOrOriginId(
|
final msg = await ms.getMessageByXmppId(
|
||||||
conversationJid,
|
|
||||||
event.messageReactions!.messageId,
|
event.messageReactions!.messageId,
|
||||||
|
conversationJid,
|
||||||
|
queryReactionPreview: false,
|
||||||
);
|
);
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
@@ -1152,81 +1132,11 @@ class XmppService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
await GetIt.I.get<ReactionsService>().processNewReactions(
|
||||||
final sender = event.fromJid.toBare().toString();
|
msg,
|
||||||
final isCarbon = sender == state.jid;
|
event.fromJid.toBare().toString(),
|
||||||
final reactions = List<Reaction>.from(msg.reactions);
|
event.messageReactions!.emojis,
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onMessage(MessageEvent event, {dynamic extra}) async {
|
Future<void> _onMessage(MessageEvent event, {dynamic extra}) async {
|
||||||
@@ -1313,84 +1223,64 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The Url of the file embedded in the message, if there is one.
|
// 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
|
// 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.
|
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||||
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
|
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
|
// Indicates if we should auto-download the file, if a file is specified in the message
|
||||||
final shouldDownload = await _shouldDownloadFile(conversationJid);
|
final shouldDownload =
|
||||||
// The thumbnail for the embedded file.
|
isFileEmbedded && await _shouldDownloadFile(conversationJid);
|
||||||
final thumbnailData = _getThumbnailData(event);
|
|
||||||
// Indicates if a notification should be created for the message.
|
// 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
|
// 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
|
// notification will be created later by the [DownloadService]. If we don't want the
|
||||||
// download to happen automatically, then the notification should happen immediately.
|
// 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.
|
// A guess for the Mime type of the embedded file.
|
||||||
var mimeGuess = _getMimeGuess(event);
|
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
|
FileMetadataWrapper? fileMetadata;
|
||||||
// - a sticker was received,
|
if (isFileEmbedded) {
|
||||||
// - the sender is in the roster,
|
final thumbnail = _getThumbnailData(event);
|
||||||
// - we don't have the sticker pack locally,
|
fileMetadata =
|
||||||
// - and it is enabled in the settings
|
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||||
if (event.stickerPackId != null &&
|
embeddedFile!,
|
||||||
stickerPack == null &&
|
mimeGuess,
|
||||||
prefs.autoDownloadStickersFromContacts &&
|
embeddedFile.size,
|
||||||
isInRoster) {
|
dimensions,
|
||||||
unawaited(
|
// TODO(Unknown): Maybe we switch to something else?
|
||||||
GetIt.I.get<StickersService>().importFromPubSubWithEvent(
|
thumbnail != null ? 'blurhash' : null,
|
||||||
event.fromJid,
|
thumbnail,
|
||||||
event.stickerPackId!,
|
createHashPointers: false,
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the message in the database
|
// Create the message in the database
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
final dimensions = _getDimensions(event);
|
|
||||||
var message = await ms.addMessageFromData(
|
var message = await ms.addMessageFromData(
|
||||||
messageBody,
|
messageBody,
|
||||||
messageTimestamp,
|
messageTimestamp,
|
||||||
event.fromJid.toString(),
|
event.fromJid.toString(),
|
||||||
conversationJid,
|
conversationJid,
|
||||||
isFileEmbedded || event.fun != null || event.stickerPackId != null,
|
|
||||||
event.sid,
|
event.sid,
|
||||||
event.fun != null,
|
event.fun != null,
|
||||||
event.encrypted,
|
event.encrypted,
|
||||||
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
|
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
|
||||||
false,
|
false,
|
||||||
srcUrl: embeddedFile?.url,
|
fileMetadata: fileMetadata?.fileMetadata,
|
||||||
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(),
|
|
||||||
quoteId: replyId,
|
quoteId: replyId,
|
||||||
originId: event.stanzaId.originId,
|
originId: event.originId,
|
||||||
errorType: errorTypeFromException(event.other['encryption_error']),
|
errorType: errorTypeFromException(event.other['encryption_error']),
|
||||||
plaintextHashes: event.sfs?.metadata.hashes,
|
|
||||||
stickerPackId: event.stickerPackId,
|
stickerPackId: event.stickerPackId,
|
||||||
stickerHashKey: stickerHashKey,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Attempt to auto-download the embedded file
|
// Attempt to auto-download the embedded file, if
|
||||||
if (isFileEmbedded && shouldDownload) {
|
// - 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 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}');
|
_log.finest('Advertised file MIME: ${metadata.mime}');
|
||||||
if (metadata.mime != null) mimeGuess = metadata.mime;
|
if (metadata.mime != null) mimeGuess = metadata.mime;
|
||||||
@@ -1408,6 +1298,7 @@ class XmppService {
|
|||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
embeddedFile,
|
embeddedFile,
|
||||||
message.id,
|
message.id,
|
||||||
|
message.fileMetadata!.id,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
mimeGuess,
|
mimeGuess,
|
||||||
),
|
),
|
||||||
@@ -1416,6 +1307,10 @@ class XmppService {
|
|||||||
// Make sure we create the notification
|
// Make sure we create the notification
|
||||||
shouldNotify = true;
|
shouldNotify = true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (fileMetadata?.retrieved ?? false) {
|
||||||
|
_log.info('Not downloading file as we already have it locally');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
@@ -1448,9 +1343,6 @@ class XmppService {
|
|||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
message.encrypted,
|
message.encrypted,
|
||||||
// Always use 0 here, since a possible shared media item only is created
|
|
||||||
// afterwards.
|
|
||||||
0,
|
|
||||||
contactId,
|
contactId,
|
||||||
await css.getProfilePicturePathForJid(conversationJid),
|
await css.getProfilePicturePathForJid(conversationJid),
|
||||||
await css.getContactDisplayName(contactId),
|
await css.getContactDisplayName(contactId),
|
||||||
@@ -1544,23 +1436,35 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The Url of the file embedded in the message, if there is one.
|
// 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?
|
// Is there even a file we can download?
|
||||||
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
|
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
|
||||||
|
|
||||||
if (isFileEmbedded) {
|
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 = await ms.updateMessage(
|
||||||
message.id,
|
message.id,
|
||||||
srcUrl: embeddedFile!.url,
|
fileMetadata: fileMetadata ?? notSpecified,
|
||||||
key: embeddedFile.keyBase64,
|
|
||||||
iv: embeddedFile.ivBase64,
|
|
||||||
isFileUploadNotification: false,
|
isFileUploadNotification: false,
|
||||||
isDownloading: shouldDownload,
|
isDownloading: shouldDownload,
|
||||||
sid: event.sid,
|
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
|
// Tell the UI
|
||||||
sendEvent(MessageUpdatedEvent(message: message));
|
sendEvent(MessageUpdatedEvent(message: message));
|
||||||
|
|
||||||
@@ -1570,11 +1474,16 @@ class XmppService {
|
|||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
embeddedFile,
|
embeddedFile,
|
||||||
message.id,
|
message.id,
|
||||||
|
oldFileMetadata!.id,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
_getMimeGuess(event),
|
_getMimeGuess(event),
|
||||||
shouldShowNotification: false,
|
shouldShowNotification: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
if (fileMetadata != null) {
|
||||||
|
_log.info('Not downloading file as we already have it locally');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.warning(
|
_log.warning(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
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/database.dart';
|
||||||
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
class XmppStateService {
|
class XmppStateService {
|
||||||
/// Persistent state around the connection, like the SM token, etc.
|
/// Persistent state around the connection, like the SM token, etc.
|
||||||
@@ -9,13 +11,29 @@ class XmppStateService {
|
|||||||
Future<XmppState> getXmppState() async {
|
Future<XmppState> getXmppState() async {
|
||||||
if (_state != null) return _state!;
|
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!;
|
return _state!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wrapper to modify the [XmppState] and commit it.
|
/// A wrapper to modify the [XmppState] and commit it.
|
||||||
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
||||||
_state = func(_state!);
|
_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:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
|
|
||||||
part 'commands.moxxy.dart';
|
part 'commands.moxxy.dart';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:moxlib/awaitabledatasender.dart';
|
import 'package:moxlib/awaitabledatasender.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.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/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.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/roster.dart';
|
||||||
import 'package:moxxyv2/shared/models/sticker_pack.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:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.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/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||||
|
|
||||||
@@ -58,7 +57,6 @@ class Conversation with _$Conversation {
|
|||||||
ConversationType type,
|
ConversationType type,
|
||||||
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
||||||
int lastChangeTimestamp,
|
int lastChangeTimestamp,
|
||||||
List<SharedMedium> sharedMedia,
|
|
||||||
// Indicates if the conversation should be shown on the homescreen
|
// Indicates if the conversation should be shown on the homescreen
|
||||||
bool open,
|
bool open,
|
||||||
// Indicates, if [jid] is a regular user, if the user is in the roster.
|
// 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)
|
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
// The current chat state
|
// The current chat state
|
||||||
@ConversationChatStateConverter() ChatState chatState,
|
@ConversationChatStateConverter() ChatState chatState, {
|
||||||
// The amount of shared media items that are in the database
|
|
||||||
int sharedMediaAmount, {
|
|
||||||
// The id of the contact in the device's phonebook if it exists
|
// The id of the contact in the device's phonebook if it exists
|
||||||
String? contactId,
|
String? contactId,
|
||||||
// The path to the contact avatar, if available
|
// The path to the contact avatar, if available
|
||||||
@@ -91,12 +87,10 @@ class Conversation with _$Conversation {
|
|||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
bool inRoster,
|
bool inRoster,
|
||||||
String subscription,
|
String subscription,
|
||||||
List<SharedMedium> sharedMedia,
|
|
||||||
Message? lastMessage,
|
Message? lastMessage,
|
||||||
) {
|
) {
|
||||||
return Conversation.fromJson({
|
return Conversation.fromJson({
|
||||||
...json,
|
...json,
|
||||||
'sharedMedia': <Map<String, dynamic>>[],
|
|
||||||
'muted': intToBool(json['muted']! as int),
|
'muted': intToBool(json['muted']! as int),
|
||||||
'open': intToBool(json['open']! as int),
|
'open': intToBool(json['open']! as int),
|
||||||
'inRoster': inRoster,
|
'inRoster': inRoster,
|
||||||
@@ -106,7 +100,6 @@ class Conversation with _$Conversation {
|
|||||||
const ConversationChatStateConverter().toJson(ChatState.gone),
|
const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||||
}).copyWith(
|
}).copyWith(
|
||||||
lastMessage: lastMessage,
|
lastMessage: lastMessage,
|
||||||
sharedMedia: sharedMedia,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +107,6 @@ class Conversation with _$Conversation {
|
|||||||
final map = toJson()
|
final map = toJson()
|
||||||
..remove('id')
|
..remove('id')
|
||||||
..remove('chatState')
|
..remove('chatState')
|
||||||
..remove('sharedMedia')
|
|
||||||
..remove('inRoster')
|
..remove('inRoster')
|
||||||
..remove('subscription')
|
..remove('subscription')
|
||||||
..remove('lastMessage');
|
..remove('lastMessage');
|
||||||
@@ -152,6 +144,9 @@ class Conversation with _$Conversation {
|
|||||||
|
|
||||||
return title;
|
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.
|
/// 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/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.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';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
|
|
||||||
part 'message.freezed.dart';
|
part 'message.freezed.dart';
|
||||||
@@ -11,24 +11,12 @@ part 'message.g.dart';
|
|||||||
|
|
||||||
const pseudoMessageTypeNewDevice = 1;
|
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) {
|
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
|
||||||
if (data == null) return <String, dynamic>{};
|
if (data == null) return <String, dynamic>{};
|
||||||
|
|
||||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<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) {
|
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||||
if (data == null) return null;
|
if (data == null) return null;
|
||||||
if (data.isEmpty) return null;
|
if (data.isEmpty) return null;
|
||||||
@@ -46,26 +34,15 @@ class Message with _$Message {
|
|||||||
// The database-internal identifier of the message
|
// The database-internal identifier of the message
|
||||||
int id,
|
int id,
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
// True if the message contains some embedded media
|
|
||||||
bool isMedia,
|
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||||
bool containsNoStore, {
|
bool containsNoStore, {
|
||||||
int? errorType,
|
int? errorType,
|
||||||
int? warningType,
|
int? warningType,
|
||||||
String? mediaUrl,
|
FileMetadata? fileMetadata,
|
||||||
@Default(false) bool isDownloading,
|
@Default(false) bool isDownloading,
|
||||||
@Default(false) bool isUploading,
|
@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 received,
|
||||||
@Default(false) bool displayed,
|
@Default(false) bool displayed,
|
||||||
@Default(false) bool acked,
|
@Default(false) bool acked,
|
||||||
@@ -73,13 +50,8 @@ class Message with _$Message {
|
|||||||
@Default(false) bool isEdited,
|
@Default(false) bool isEdited,
|
||||||
String? originId,
|
String? originId,
|
||||||
Message? quotes,
|
Message? quotes,
|
||||||
String? filename,
|
@Default([]) List<String> reactionsPreview,
|
||||||
Map<String, String>? plaintextHashes,
|
|
||||||
Map<String, String>? ciphertextHashes,
|
|
||||||
int? mediaSize,
|
|
||||||
@Default([]) List<Reaction> reactions,
|
|
||||||
String? stickerPackId,
|
String? stickerPackId,
|
||||||
String? stickerHashKey,
|
|
||||||
int? pseudoMessageType,
|
int? pseudoMessageType,
|
||||||
Map<String, dynamic>? pseudoMessageData,
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
}) = _Message;
|
}) = _Message;
|
||||||
@@ -90,34 +62,31 @@ class Message with _$Message {
|
|||||||
factory Message.fromJson(Map<String, dynamic> json) =>
|
factory Message.fromJson(Map<String, dynamic> json) =>
|
||||||
_$MessageFromJson(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({
|
return Message.fromJson({
|
||||||
...json,
|
...json,
|
||||||
'received': intToBool(json['received']! as int),
|
'received': intToBool(json['received']! as int),
|
||||||
'displayed': intToBool(json['displayed']! as int),
|
'displayed': intToBool(json['displayed']! as int),
|
||||||
'acked': intToBool(json['acked']! as int),
|
'acked': intToBool(json['acked']! as int),
|
||||||
'isMedia': intToBool(json['isMedia']! as int),
|
|
||||||
'isFileUploadNotification':
|
'isFileUploadNotification':
|
||||||
intToBool(json['isFileUploadNotification']! as int),
|
intToBool(json['isFileUploadNotification']! as int),
|
||||||
'encrypted': intToBool(json['encrypted']! 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),
|
'isDownloading': intToBool(json['isDownloading']! as int),
|
||||||
'isUploading': intToBool(json['isUploading']! as int),
|
'isUploading': intToBool(json['isUploading']! as int),
|
||||||
'isRetracted': intToBool(json['isRetracted']! as int),
|
'isRetracted': intToBool(json['isRetracted']! as int),
|
||||||
'isEdited': intToBool(json['isEdited']! as int),
|
'isEdited': intToBool(json['isEdited']! as int),
|
||||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||||
'reactions': <Map<String, dynamic>>[],
|
'reactionsPreview': reactionsPreview,
|
||||||
'pseudoMessageData':
|
'pseudoMessageData':
|
||||||
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
||||||
}).copyWith(
|
}).copyWith(
|
||||||
quotes: quotes,
|
quotes: quotes,
|
||||||
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
|
fileMetadata: fileMetadata,
|
||||||
.cast<Map<String, dynamic>>()
|
|
||||||
.map<Reaction>(Reaction.fromJson)
|
|
||||||
.toList(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,29 +94,25 @@ class Message with _$Message {
|
|||||||
final map = toJson()
|
final map = toJson()
|
||||||
..remove('id')
|
..remove('id')
|
||||||
..remove('quotes')
|
..remove('quotes')
|
||||||
..remove('reactions')
|
..remove('reactionsPreview')
|
||||||
|
..remove('fileMetadata')
|
||||||
..remove('pseudoMessageData');
|
..remove('pseudoMessageData');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...map,
|
...map,
|
||||||
'isMedia': boolToInt(isMedia),
|
|
||||||
'isFileUploadNotification': boolToInt(isFileUploadNotification),
|
'isFileUploadNotification': boolToInt(isFileUploadNotification),
|
||||||
'received': boolToInt(received),
|
'received': boolToInt(received),
|
||||||
'displayed': boolToInt(displayed),
|
'displayed': boolToInt(displayed),
|
||||||
'acked': boolToInt(acked),
|
'acked': boolToInt(acked),
|
||||||
'encrypted': boolToInt(encrypted),
|
'encrypted': boolToInt(encrypted),
|
||||||
|
'file_metadata_id': fileMetadata?.id,
|
||||||
// NOTE: Message.quote_id is a foreign-key
|
// NOTE: Message.quote_id is a foreign-key
|
||||||
'quote_id': quotes?.id,
|
'quote_id': quotes?.id,
|
||||||
'plaintextHashes': _optionalJsonEncode(plaintextHashes),
|
|
||||||
'ciphertextHashes': _optionalJsonEncode(ciphertextHashes),
|
|
||||||
'isDownloading': boolToInt(isDownloading),
|
'isDownloading': boolToInt(isDownloading),
|
||||||
'isUploading': boolToInt(isUploading),
|
'isUploading': boolToInt(isUploading),
|
||||||
'isRetracted': boolToInt(isRetracted),
|
'isRetracted': boolToInt(isRetracted),
|
||||||
'isEdited': boolToInt(isEdited),
|
'isEdited': boolToInt(isEdited),
|
||||||
'containsNoStore': boolToInt(containsNoStore),
|
'containsNoStore': boolToInt(containsNoStore),
|
||||||
'reactions': jsonEncode(
|
|
||||||
reactions.map((r) => r.toJson()).toList(),
|
|
||||||
),
|
|
||||||
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -161,7 +126,7 @@ class Message with _$Message {
|
|||||||
/// Returns a representative emoji for a message. Its primary purpose is
|
/// Returns a representative emoji for a message. Its primary purpose is
|
||||||
/// to provide a universal fallback for quoted media messages.
|
/// to provide a universal fallback for quoted media messages.
|
||||||
String get messageEmoji {
|
String get messageEmoji {
|
||||||
return mimeTypeToEmoji(mediaType, addTypeName: false);
|
return mimeTypeToEmoji(fileMetadata?.mimeType, addTypeName: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the message is a pseudo message.
|
/// 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
|
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||||
/// images.
|
/// images.
|
||||||
bool get isThumbnailable =>
|
bool get isThumbnailable {
|
||||||
!isPseudoMessage &&
|
if (isPseudoMessage || !isMedia || fileMetadata?.mimeType == null) {
|
||||||
isMedia &&
|
return false;
|
||||||
mediaType != null &&
|
}
|
||||||
(mediaType!.startsWith('image/') || mediaType!.startsWith('video/'));
|
|
||||||
|
final mimeType = fileMetadata!.mimeType!;
|
||||||
|
return mimeType.startsWith('image/') || mimeType.startsWith('video/');
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the message can be copied to the clipboard.
|
/// Returns true if the message can be copied to the clipboard.
|
||||||
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||||
|
|
||||||
/// Returns true if the message is a sticker
|
/// Returns true if the message is a sticker
|
||||||
bool get isSticker =>
|
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
|
||||||
isMedia &&
|
|
||||||
stickerPackId != null &&
|
/// True if the message is a media message
|
||||||
stickerHashKey != null &&
|
bool get isMedia => fileMetadata != null;
|
||||||
!isPseudoMessage;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,14 @@ part 'reaction.g.dart';
|
|||||||
@freezed
|
@freezed
|
||||||
class Reaction with _$Reaction {
|
class Reaction with _$Reaction {
|
||||||
factory 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,
|
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;
|
) = _Reaction;
|
||||||
|
|
||||||
const Reaction._();
|
|
||||||
|
|
||||||
/// JSON
|
/// JSON
|
||||||
factory Reaction.fromJson(Map<String, dynamic> json) =>
|
factory Reaction.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ReactionFromJson(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 'dart:convert';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
import 'package:moxxyv2/service/helpers.dart';
|
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.freezed.dart';
|
||||||
part 'sticker.g.dart';
|
part 'sticker.g.dart';
|
||||||
@@ -9,84 +12,89 @@ part 'sticker.g.dart';
|
|||||||
@freezed
|
@freezed
|
||||||
class Sticker with _$Sticker {
|
class Sticker with _$Sticker {
|
||||||
factory Sticker(
|
factory Sticker(
|
||||||
String hashKey,
|
String id,
|
||||||
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 stickerPackId,
|
String stickerPackId,
|
||||||
|
String desc,
|
||||||
Map<String, String> suggests,
|
Map<String, String> suggests,
|
||||||
|
FileMetadata fileMetadata,
|
||||||
) = _Sticker;
|
) = _Sticker;
|
||||||
|
|
||||||
const Sticker._();
|
const Sticker._();
|
||||||
|
|
||||||
/// Moxxmpp
|
/// Moxxmpp
|
||||||
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) =>
|
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) {
|
||||||
Sticker(
|
final hashKey = getStickerHashKey(sticker.metadata.hashes);
|
||||||
getStickerHashKey(sticker.metadata.hashes),
|
final firstUrl = (sticker.sources.firstWhereOrNull(
|
||||||
sticker.metadata.mediaType!,
|
(src) => src is moxxmpp.StatelessFileSharingUrlSource,
|
||||||
sticker.metadata.desc!,
|
)! as moxxmpp.StatelessFileSharingUrlSource)
|
||||||
sticker.metadata.size!,
|
.url;
|
||||||
sticker.metadata.width,
|
return Sticker(
|
||||||
sticker.metadata.height,
|
hashKey,
|
||||||
sticker.metadata.hashes,
|
stickerPackId,
|
||||||
|
sticker.metadata.desc!,
|
||||||
|
sticker.suggests,
|
||||||
|
FileMetadata(
|
||||||
|
hashKey,
|
||||||
|
null,
|
||||||
sticker.sources
|
sticker.sources
|
||||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||||
.map((src) => src.url)
|
.map((src) => src.url)
|
||||||
.toList(),
|
.toList(),
|
||||||
'',
|
sticker.metadata.mediaType,
|
||||||
stickerPackId,
|
sticker.metadata.size,
|
||||||
sticker.suggests,
|
null,
|
||||||
);
|
null,
|
||||||
|
sticker.metadata.width,
|
||||||
|
sticker.metadata.height,
|
||||||
|
sticker.metadata.hashes,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sticker.metadata.name ?? path.basename(firstUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// JSON
|
/// JSON
|
||||||
factory Sticker.fromJson(Map<String, dynamic> json) =>
|
factory Sticker.fromJson(Map<String, dynamic> json) =>
|
||||||
_$StickerFromJson(json);
|
_$StickerFromJson(json);
|
||||||
|
|
||||||
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) {
|
factory Sticker.fromDatabaseJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
FileMetadata fileMetadata,
|
||||||
|
) {
|
||||||
return Sticker.fromJson({
|
return Sticker.fromJson({
|
||||||
...json,
|
...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':
|
'suggests':
|
||||||
(jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>)
|
(jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>)
|
||||||
.cast<String, String>(),
|
.cast<String, String>(),
|
||||||
|
'fileMetadata': fileMetadata.toJson(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toDatabaseJson() {
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
final map = toJson()
|
final map = toJson()..remove('fileMetadata');
|
||||||
..remove('hashes')
|
|
||||||
..remove('urlSources')
|
|
||||||
..remove('suggests');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...map,
|
...map,
|
||||||
'hashes': jsonEncode(hashes),
|
|
||||||
'urlSources': jsonEncode(urlSources),
|
|
||||||
'suggests': jsonEncode(suggests),
|
'suggests': jsonEncode(suggests),
|
||||||
|
'file_metadata_id': fileMetadata.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
|
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
|
||||||
moxxmpp.FileMetadataData(
|
moxxmpp.FileMetadataData(
|
||||||
mediaType: mediaType,
|
mediaType: fileMetadata.mimeType,
|
||||||
desc: desc,
|
desc: desc,
|
||||||
size: size,
|
size: fileMetadata.size,
|
||||||
width: width,
|
width: fileMetadata.width,
|
||||||
height: height,
|
height: fileMetadata.height,
|
||||||
thumbnails: [],
|
thumbnails: [],
|
||||||
hashes: hashes,
|
hashes: fileMetadata.plaintextHashes,
|
||||||
),
|
),
|
||||||
urlSources
|
fileMetadata.sourceUrls!
|
||||||
|
// Dart has some issues with using a constructor in a map
|
||||||
// ignore: unnecessary_lambdas
|
// ignore: unnecessary_lambdas
|
||||||
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
|
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
|
||||||
.toList(),
|
.toList(),
|
||||||
@@ -94,5 +102,5 @@ class Sticker with _$Sticker {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// True, if the sticker is backed by an image with MIME type image/*.
|
/// 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,
|
id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
moxxmpp.hashFunctionFromName(hashAlgorithm),
|
moxxmpp.HashFunction.fromName(hashAlgorithm),
|
||||||
hashValue,
|
hashValue,
|
||||||
stickers.map((sticker) => sticker.toMoxxmpp()).toList(),
|
stickers.map((sticker) => sticker.toMoxxmpp()).toList(),
|
||||||
restricted,
|
restricted,
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:flutter/widgets.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:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.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_bloc.freezed.dart';
|
||||||
part 'conversation_event.dart';
|
part 'conversation_event.dart';
|
||||||
@@ -36,19 +28,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||||
on<OmemoSetEvent>(_onOmemoSet);
|
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;
|
bool _isSameConversation(String jid) => jid == state.conversation?.jid;
|
||||||
|
|
||||||
Future<void> _onInit(
|
Future<void> _onInit(
|
||||||
@@ -135,14 +116,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
CurrentConversationResetEvent event,
|
CurrentConversationResetEvent event,
|
||||||
Emitter<ConversationState> emit,
|
Emitter<ConversationState> emit,
|
||||||
) async {
|
) 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(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
SetOpenConversationCommand(),
|
SetOpenConversationCommand(),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
@@ -209,128 +182,4 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
awaitable: false,
|
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';
|
part of 'conversation_bloc.dart';
|
||||||
|
|
||||||
enum SendButtonState {
|
enum SendButtonState {
|
||||||
|
/// Open the speed dial when tapped.
|
||||||
multi,
|
multi,
|
||||||
|
|
||||||
|
/// Send the current message when tapped.
|
||||||
send,
|
send,
|
||||||
|
|
||||||
|
/// Cancel the current correction when tapped.
|
||||||
cancelCorrection,
|
cancelCorrection,
|
||||||
|
|
||||||
|
/// Hide the button when we're recording an audio message.
|
||||||
|
hidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSendButtonState = SendButtonState.multi;
|
const defaultSendButtonState = SendButtonState.multi;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
@@ -147,4 +148,10 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
|||||||
awaitable: false,
|
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/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/profile/profile.dart';
|
||||||
|
|
||||||
part 'profile_bloc.freezed.dart';
|
part 'profile_bloc.freezed.dart';
|
||||||
part 'profile_event.dart';
|
part 'profile_event.dart';
|
||||||
@@ -28,7 +29,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
|||||||
if (event.isSelfProfile) {
|
if (event.isSelfProfile) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
isSelfProfile: true,
|
|
||||||
jid: event.jid!,
|
jid: event.jid!,
|
||||||
avatarUrl: event.avatarUrl!,
|
avatarUrl: event.avatarUrl!,
|
||||||
displayName: event.displayName!,
|
displayName: event.displayName!,
|
||||||
@@ -37,7 +37,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
|||||||
} else {
|
} else {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
isSelfProfile: false,
|
|
||||||
conversation: event.conversation,
|
conversation: event.conversation,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -45,8 +44,12 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
|||||||
|
|
||||||
GetIt.I.get<NavigationBloc>().add(
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
PushedNamedEvent(
|
PushedNamedEvent(
|
||||||
const NavigationDestination(
|
NavigationDestination(
|
||||||
profileRoute,
|
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) {
|
for (final sticker in pack.stickers) {
|
||||||
if (!sticker.isImage) continue;
|
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);
|
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
||||||
for (final sticker in stickerPack.stickers) {
|
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
|
// Evict stickers from the cache
|
||||||
unawaited(FileImage(File(sticker.path)).evict());
|
unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
@@ -105,7 +105,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
|||||||
for (final sticker in result.stickerPack.stickers) {
|
for (final sticker in result.stickerPack.stickers) {
|
||||||
if (!sticker.isImage) continue;
|
if (!sticker.isImage) continue;
|
||||||
|
|
||||||
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker;
|
sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
|
||||||
}
|
}
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -146,7 +146,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
|||||||
for (final sticker in event.stickerPack.stickers) {
|
for (final sticker in event.stickerPack.stickers) {
|
||||||
if (!sticker.isImage) continue;
|
if (!sticker.isImage) continue;
|
||||||
|
|
||||||
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker;
|
sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 Radius radiusSmall = Radius.circular(4);
|
||||||
|
|
||||||
const double textfieldRadiusRegular = 15;
|
const double textfieldRadiusRegular = 15;
|
||||||
const double textfieldRadiusConversation = 25;
|
const double textfieldRadiusConversation = 25;
|
||||||
|
const double textfieldQuotedMessageRadius = textfieldRadiusConversation - 10;
|
||||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
|
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
|
||||||
top: 4,
|
top: 4,
|
||||||
bottom: 4,
|
bottom: 4,
|
||||||
@@ -93,6 +95,9 @@ const Color profileFallbackTextColorDark = Colors.white;
|
|||||||
/// The text color of the buttons in the overlay of the ConversationPage
|
/// The text color of the buttons in the overlay of the ConversationPage
|
||||||
const Color conversationOverlayButtonTextColor = Color(0xffcf4aff);
|
const Color conversationOverlayButtonTextColor = Color(0xffcf4aff);
|
||||||
|
|
||||||
|
/// The background color of the context menu
|
||||||
|
const Color contextMenuBackgroundColor = Color(0xff515151);
|
||||||
|
|
||||||
const Color settingsSectionTitleColor = Color(0xffb72fe7);
|
const Color settingsSectionTitleColor = Color(0xffb72fe7);
|
||||||
|
|
||||||
const double paddingVeryLarge = 64;
|
const double paddingVeryLarge = 64;
|
||||||
@@ -117,9 +122,18 @@ final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
|
|||||||
/// displaying the download progress indicator.
|
/// displaying the download progress indicator.
|
||||||
final backdropBlack = Colors.black.withAlpha(150);
|
final backdropBlack = Colors.black.withAlpha(150);
|
||||||
|
|
||||||
/// The height of the emoji/sticker picker
|
/// The height of the emoji/sticker picker.
|
||||||
const double pickerHeight = 300;
|
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
|
/// Navigation constants
|
||||||
const String cropRoute = '/crop';
|
const String cropRoute = '/crop';
|
||||||
const String introRoute = '/intro';
|
const String introRoute = '/intro';
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ class BidirectionalController<T> {
|
|||||||
/// _cache.length - 1: The newest data item we know about
|
/// _cache.length - 1: The newest data item we know about
|
||||||
final List<T> _cache = List<T>.empty(growable: true);
|
final List<T> _cache = List<T>.empty(growable: true);
|
||||||
final StreamController<List<T>> _dataStreamController =
|
final StreamController<List<T>> _dataStreamController =
|
||||||
StreamController<List<T>>();
|
StreamController<List<T>>.broadcast();
|
||||||
Stream<List<T>> get dataStream => _dataStreamController.stream;
|
Stream<List<T>> get dataStream => _dataStreamController.stream;
|
||||||
|
|
||||||
@protected
|
//@protected
|
||||||
List<T> get cache => _cache;
|
List<T> get cache => _cache;
|
||||||
|
|
||||||
/// True if the cache has exceeded the size limit of pageSize * maxPageAmount.
|
/// 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
|
/// Flag indicating whether we are currently fetching data
|
||||||
bool _isFetching = false;
|
bool _isFetching = false;
|
||||||
|
bool get isFetching => _isFetching;
|
||||||
final StreamController<bool> _isFetchingStreamController =
|
final StreamController<bool> _isFetchingStreamController =
|
||||||
StreamController<bool>();
|
StreamController<bool>.broadcast();
|
||||||
Stream<bool> get isFetchingStream => _isFetchingStreamController.stream;
|
Stream<bool> get isFetchingStream => _isFetchingStreamController.stream;
|
||||||
|
|
||||||
/// Flag indicating whether we are able to request newer data
|
/// Flag indicating whether we are able to request newer data
|
||||||
@@ -53,7 +54,6 @@ class BidirectionalController<T> {
|
|||||||
bool hasOlderData = true;
|
bool hasOlderData = true;
|
||||||
|
|
||||||
/// Flag indicating whether data has been loaded at least once
|
/// Flag indicating whether data has been loaded at least once
|
||||||
@protected
|
|
||||||
bool hasFetchedOnce = false;
|
bool hasFetchedOnce = false;
|
||||||
|
|
||||||
/// True if we are scrolled to the bottom of the view. False, otherwise.
|
/// True if we are scrolled to the bottom of the view. False, otherwise.
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/services.dart';
|
import 'dart:io';
|
||||||
import 'package:flutter/widgets.dart';
|
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:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/constants.dart';
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.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/bloc/conversation_bloc.dart' as conversation;
|
||||||
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
|
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 {
|
class MessageEditingState {
|
||||||
const MessageEditingState(
|
const MessageEditingState(
|
||||||
@@ -37,7 +44,6 @@ class TextFieldData {
|
|||||||
const TextFieldData(
|
const TextFieldData(
|
||||||
this.isBodyEmpty,
|
this.isBodyEmpty,
|
||||||
this.quotedMessage,
|
this.quotedMessage,
|
||||||
this.pickerVisible,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Flag indicating whether the current text input is empty.
|
/// Flag indicating whether the current text input is empty.
|
||||||
@@ -45,9 +51,19 @@ class TextFieldData {
|
|||||||
|
|
||||||
/// The currently quoted message.
|
/// The currently quoted message.
|
||||||
final Message? quotedMessage;
|
final Message? quotedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
/// Flag indicating whether the picker is currently open or not.
|
class RecordingData {
|
||||||
final bool pickerVisible;
|
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
|
class BidirectionalConversationController
|
||||||
@@ -62,21 +78,19 @@ class BidirectionalConversationController
|
|||||||
maxPageAmount: maxMessagePages,
|
maxPageAmount: maxMessagePages,
|
||||||
) {
|
) {
|
||||||
_textController.addListener(_handleTextChanged);
|
_textController.addListener(_handleTextChanged);
|
||||||
_keyboardVisibilitySubscription = KeyboardVisibilityController()
|
|
||||||
.onChange
|
|
||||||
.listen(_handleSoftKeyboardVisibilityChanged);
|
|
||||||
|
|
||||||
BidirectionalConversationController.currentController = this;
|
BidirectionalConversationController.currentController = this;
|
||||||
|
|
||||||
_updateChatState(ChatState.active);
|
_updateChatState(ChatState.active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Logging.
|
||||||
|
final Logger _log = Logger('BidirectionalConversationController');
|
||||||
|
|
||||||
/// A singleton referring to the current instance as there can only be one
|
/// A singleton referring to the current instance as there can only be one
|
||||||
/// BidirectionalConversationController at a time.
|
/// BidirectionalConversationController at a time.
|
||||||
static BidirectionalConversationController? currentController;
|
static BidirectionalConversationController? currentController;
|
||||||
|
|
||||||
late final StreamSubscription<bool> _keyboardVisibilitySubscription;
|
|
||||||
|
|
||||||
/// TextEditingController for the TextField
|
/// TextEditingController for the TextField
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
TextEditingController get textController => _textController;
|
TextEditingController get textController => _textController;
|
||||||
@@ -109,18 +123,21 @@ class BidirectionalConversationController
|
|||||||
Stream<TextFieldData> get textFieldDataStream =>
|
Stream<TextFieldData> get textFieldDataStream =>
|
||||||
_textFieldDataStreamController.stream;
|
_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
|
/// The timer for managing the "compose" state
|
||||||
Timer? _composeTimer;
|
Timer? _composeTimer;
|
||||||
|
|
||||||
/// The last time the TextField was modified
|
/// The last time the TextField was modified
|
||||||
int _lastChangeTimestamp = 0;
|
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) {
|
void _updateChatState(ChatState state) {
|
||||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
SendChatStateCommand(
|
SendChatStateCommand(
|
||||||
@@ -155,12 +172,6 @@ class BidirectionalConversationController
|
|||||||
_composeTimer = null;
|
_composeTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSoftKeyboardVisibilityChanged(bool visible) {
|
|
||||||
if (visible && _pickerVisible) {
|
|
||||||
togglePickerVisibility(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTextChanged() {
|
void _handleTextChanged() {
|
||||||
final text = _textController.text;
|
final text = _textController.text;
|
||||||
if (_messageEditingState != null) {
|
if (_messageEditingState != null) {
|
||||||
@@ -181,7 +192,6 @@ class BidirectionalConversationController
|
|||||||
TextFieldData(
|
TextFieldData(
|
||||||
messageBody.isEmpty,
|
messageBody.isEmpty,
|
||||||
_quotedMessage,
|
_quotedMessage,
|
||||||
_pickerVisible,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,13 +216,28 @@ class BidirectionalConversationController
|
|||||||
|
|
||||||
Future<void> onMessageReceived(Message message) async {
|
Future<void> onMessageReceived(Message message) async {
|
||||||
// Drop the message if we don't really care about it
|
// 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
|
// TODO(Unknown): This is probably not the best solution
|
||||||
// messages.
|
if (isFetching) {
|
||||||
|
_log.finest('Not processing message as we are currently fetching');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var shouldScrollToBottom = true;
|
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) {
|
if (message.timestamp < cache.first.timestamp) {
|
||||||
// The message is older than the oldest message we know about. Drop it.
|
// The message is older than the oldest message we know about. Drop it.
|
||||||
// It will be fetched when scrolling up.
|
// It will be fetched when scrolling up.
|
||||||
@@ -264,97 +289,19 @@ class BidirectionalConversationController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add [emoji] as a reaction to the message at index [index].
|
/// Send the sticker [sticker].
|
||||||
void addReaction(int index, String emoji) {
|
void sendSticker(sticker.Sticker sticker) {
|
||||||
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) {
|
|
||||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
SendStickerCommand(
|
SendStickerCommand(
|
||||||
stickerPackId: packId,
|
sticker: sticker,
|
||||||
stickerHashKey: hashKey,
|
|
||||||
recipient: conversationJid,
|
recipient: conversationJid,
|
||||||
|
quotes: _quotedMessage,
|
||||||
),
|
),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close the picker
|
// Remove a possible quote
|
||||||
togglePickerVisibility(false);
|
removeQuote();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(bool encrypted) async {
|
Future<void> sendMessage(bool encrypted) async {
|
||||||
@@ -444,7 +391,6 @@ class BidirectionalConversationController
|
|||||||
TextFieldData(
|
TextFieldData(
|
||||||
messageBody.isEmpty,
|
messageBody.isEmpty,
|
||||||
message,
|
message,
|
||||||
_pickerVisible,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -456,7 +402,6 @@ class BidirectionalConversationController
|
|||||||
TextFieldData(
|
TextFieldData(
|
||||||
messageBody.isEmpty,
|
messageBody.isEmpty,
|
||||||
null,
|
null,
|
||||||
_pickerVisible,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -491,37 +436,104 @@ class BidirectionalConversationController
|
|||||||
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggles the visibility of the (emoji/sticker) picker
|
Future<void> startAudioMessageRecording() async {
|
||||||
void togglePickerVisibility(bool handleKeyboard) {
|
final status = await Permission.speech.status;
|
||||||
final newState = !_pickerVisible;
|
if (status.isDenied) {
|
||||||
|
await Permission.speech.request();
|
||||||
if (handleKeyboard) {
|
return;
|
||||||
if (newState) {
|
|
||||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
|
||||||
} else {
|
|
||||||
SystemChannels.textInput.invokeMethod('TextInput.show');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_pickerVisible = newState;
|
_recordingAudioMessageStreamController.add(
|
||||||
_pickerVisibleStreamController.add(newState);
|
const RecordingData(
|
||||||
_textFieldDataStreamController.add(
|
true,
|
||||||
TextFieldData(
|
false,
|
||||||
messageBody.isEmpty,
|
),
|
||||||
_quotedMessage,
|
);
|
||||||
newState,
|
_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.
|
Future<void> cancelAudioMessageRecording() async {
|
||||||
bool handlePop() {
|
Vibrate.feedback(FeedbackType.heavy);
|
||||||
if (_pickerVisible) {
|
_recordingAudioMessageStreamController.add(
|
||||||
togglePickerVisibility(false);
|
const RecordingData(
|
||||||
return false;
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
||||||
|
|
||||||
|
_recordingStart = null;
|
||||||
|
final file = await _audioRecorder.stop();
|
||||||
|
unawaited(File(file!).delete());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> endAudioMessageRecording() async {
|
||||||
|
_recordingAudioMessageStreamController.add(
|
||||||
|
const RecordingData(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_sendButtonStreamController.add(conversation.defaultSendButtonState);
|
||||||
|
|
||||||
|
if (_recordingStart == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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
|
/// React to app livecycle changes
|
||||||
@@ -533,11 +545,16 @@ class BidirectionalConversationController
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
super.dispose();
|
// Reset the singleton
|
||||||
BidirectionalConversationController.currentController = null;
|
BidirectionalConversationController.currentController = null;
|
||||||
|
|
||||||
|
// Dispose of controllers
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
_keyboardVisibilitySubscription.cancel();
|
_audioRecorder.dispose();
|
||||||
|
|
||||||
|
// Tell the contact that we're gone
|
||||||
_updateChatState(ChatState.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/commands.dart';
|
||||||
import 'package:moxxyv2/shared/constants.dart';
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
import 'package:moxxyv2/shared/events.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';
|
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
|
||||||
|
|
||||||
class BidirectionalSharedMediaController
|
class BidirectionalSharedMediaController
|
||||||
extends BidirectionalController<SharedMedium> {
|
extends BidirectionalController<Message> {
|
||||||
BidirectionalSharedMediaController(this.conversationJid)
|
BidirectionalSharedMediaController(this.conversationJid)
|
||||||
: assert(
|
: assert(
|
||||||
BidirectionalSharedMediaController.currentController == null,
|
BidirectionalSharedMediaController.currentController == null,
|
||||||
@@ -24,11 +24,12 @@ class BidirectionalSharedMediaController
|
|||||||
/// BidirectionalConversationController at a time.
|
/// BidirectionalConversationController at a time.
|
||||||
static BidirectionalSharedMediaController? currentController;
|
static BidirectionalSharedMediaController? currentController;
|
||||||
|
|
||||||
|
/// The JID of the conversation we want to get shared media of.
|
||||||
final String conversationJid;
|
final String conversationJid;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<SharedMedium>> fetchOlderDataImpl(
|
Future<List<Message>> fetchOlderDataImpl(
|
||||||
SharedMedium? oldestElement,
|
Message? oldestElement,
|
||||||
) async {
|
) async {
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
@@ -37,14 +38,14 @@ class BidirectionalSharedMediaController
|
|||||||
timestamp: oldestElement?.timestamp,
|
timestamp: oldestElement?.timestamp,
|
||||||
olderThan: true,
|
olderThan: true,
|
||||||
),
|
),
|
||||||
) as PagedSharedMediaResultEvent;
|
) as PagedMessagesResultEvent;
|
||||||
|
|
||||||
return result.media;
|
return result.messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<SharedMedium>> fetchNewerDataImpl(
|
Future<List<Message>> fetchNewerDataImpl(
|
||||||
SharedMedium? newestElement,
|
Message? newestElement,
|
||||||
) async {
|
) async {
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
@@ -53,9 +54,9 @@ class BidirectionalSharedMediaController
|
|||||||
timestamp: newestElement?.timestamp,
|
timestamp: newestElement?.timestamp,
|
||||||
olderThan: false,
|
olderThan: false,
|
||||||
),
|
),
|
||||||
) as PagedSharedMediaResultEvent;
|
) as PagedMessagesResultEvent;
|
||||||
|
|
||||||
return result.media;
|
return result.messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:better_open_file/better_open_file.dart';
|
import 'package:better_open_file/better_open_file.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.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;
|
return true;
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
|
appBar: BorderlessTopbar.title(t.pages.addcontact.title),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
Visibility(
|
||||||
|
|||||||
@@ -94,38 +94,35 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<BlocklistBloc, BlocklistState>(
|
return BlocBuilder<BlocklistBloc, BlocklistState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(
|
appBar: BorderlessTopbar.title(
|
||||||
t.pages.blocklist.title,
|
t.pages.blocklist.title,
|
||||||
extra: [
|
trailing: PopupMenuButton(
|
||||||
Expanded(child: Container()),
|
onSelected: (BlocklistOptions result) async {
|
||||||
PopupMenuButton(
|
if (result == BlocklistOptions.unblockAll) {
|
||||||
onSelected: (BlocklistOptions result) async {
|
final result = await showConfirmationDialog(
|
||||||
if (result == BlocklistOptions.unblockAll) {
|
t.pages.blocklist.unblockAllConfirmTitle,
|
||||||
final result = await showConfirmationDialog(
|
t.pages.blocklist.unblockAllConfirmBody,
|
||||||
t.pages.blocklist.unblockAllConfirmTitle,
|
context,
|
||||||
t.pages.blocklist.unblockAllConfirmBody,
|
);
|
||||||
context,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
context.read<BlocklistBloc>().add(UnblockedAllEvent());
|
context.read<BlocklistBloc>().add(UnblockedAllEvent());
|
||||||
|
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
icon: const Icon(Icons.more_vert),
|
},
|
||||||
itemBuilder: (BuildContext context) => [
|
icon: const Icon(Icons.more_vert),
|
||||||
PopupMenuItem(
|
itemBuilder: (BuildContext context) => [
|
||||||
enabled: state.blocklist.isNotEmpty,
|
PopupMenuItem(
|
||||||
value: BlocklistOptions.unblockAll,
|
enabled: state.blocklist.isNotEmpty,
|
||||||
child: Text(t.pages.blocklist.unblockAll),
|
value: BlocklistOptions.unblockAll,
|
||||||
),
|
child: Text(t.pages.blocklist.unblockAll),
|
||||||
],
|
),
|
||||||
)
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
body: _buildListView(state),
|
body: _buildListView(state),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:math';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||||
@@ -11,29 +10,46 @@ import 'package:moxxyv2/ui/constants.dart';
|
|||||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/blink.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/pages/conversation/timer.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
import 'package:moxxyv2/ui/theme.dart';
|
import 'package:moxxyv2/ui/theme.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/message.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:moxxyv2/ui/widgets/textfield.dart';
|
||||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
|
|
||||||
class _TextFieldIconButton extends StatelessWidget {
|
class _TextFieldIconButton extends StatelessWidget {
|
||||||
const _TextFieldIconButton(this.icon, this.onTap);
|
const _TextFieldIconButton({
|
||||||
final void Function() onTap;
|
required this.keyboardController,
|
||||||
final IconData icon;
|
required this.tabController,
|
||||||
|
required this.textfieldFocusNode,
|
||||||
|
});
|
||||||
|
|
||||||
|
final KeyboardReplacerController keyboardController;
|
||||||
|
final TabController tabController;
|
||||||
|
|
||||||
|
final FocusNode textfieldFocusNode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: () {
|
||||||
|
keyboardController.toggleWidget(context, textfieldFocusNode);
|
||||||
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Icon(
|
child: StreamBuilder<KeyboardReplacerData>(
|
||||||
icon,
|
stream: keyboardController.stream,
|
||||||
size: 24,
|
initialData: keyboardController.currentData,
|
||||||
color: primaryColor,
|
builder: (context, snapshot) => Icon(
|
||||||
|
snapshot.data!.showWidget
|
||||||
|
? Icons.keyboard
|
||||||
|
: (tabController.index == 0
|
||||||
|
? Icons.insert_emoticon
|
||||||
|
: PhosphorIcons.stickerBold),
|
||||||
|
size: 24,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -41,7 +57,13 @@ class _TextFieldIconButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TextFieldRecordButton extends StatelessWidget {
|
class _TextFieldRecordButton extends StatelessWidget {
|
||||||
const _TextFieldRecordButton();
|
const _TextFieldRecordButton({
|
||||||
|
required this.conversationController,
|
||||||
|
required this.keyboardController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BidirectionalConversationController conversationController;
|
||||||
|
final KeyboardReplacerController keyboardController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -50,15 +72,15 @@ class _TextFieldRecordButton extends StatelessWidget {
|
|||||||
axis: Axis.vertical,
|
axis: Axis.vertical,
|
||||||
onDragStarted: () {
|
onDragStarted: () {
|
||||||
Vibrate.feedback(FeedbackType.heavy);
|
Vibrate.feedback(FeedbackType.heavy);
|
||||||
context.read<ConversationBloc>().add(
|
|
||||||
SendButtonDragStartedEvent(),
|
conversationController.startAudioMessageRecording();
|
||||||
);
|
keyboardController.hideWidget();
|
||||||
|
dismissSoftKeyboard(context);
|
||||||
},
|
},
|
||||||
onDraggableCanceled: (_, __) {
|
onDraggableCanceled: (_, __) {
|
||||||
Vibrate.feedback(FeedbackType.heavy);
|
Vibrate.feedback(FeedbackType.heavy);
|
||||||
context.read<ConversationBloc>().add(
|
|
||||||
SendButtonDragEndedEvent(),
|
conversationController.endAudioMessageRecording();
|
||||||
);
|
|
||||||
},
|
},
|
||||||
childWhenDragging: const SizedBox(),
|
childWhenDragging: const SizedBox(),
|
||||||
feedback: SizedBox(
|
feedback: SizedBox(
|
||||||
@@ -88,26 +110,37 @@ class _TextFieldRecordButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConversationBottomRow extends StatefulWidget {
|
class ConversationInput extends StatefulWidget {
|
||||||
const ConversationBottomRow(
|
const ConversationInput({
|
||||||
this.tabController,
|
required this.keyboardController,
|
||||||
this.focusNode,
|
required this.conversationController,
|
||||||
this.conversationController,
|
required this.tabController,
|
||||||
this.speedDialValueNotifier, {
|
required this.speedDialValueNotifier,
|
||||||
|
required this.isEncrypted,
|
||||||
|
required this.textfieldFocusNode,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final TabController tabController;
|
|
||||||
final FocusNode focusNode;
|
final KeyboardReplacerController keyboardController;
|
||||||
final ValueNotifier<bool> speedDialValueNotifier;
|
|
||||||
final BidirectionalConversationController conversationController;
|
final BidirectionalConversationController conversationController;
|
||||||
|
|
||||||
|
final TabController tabController;
|
||||||
|
|
||||||
|
final ValueNotifier<bool> speedDialValueNotifier;
|
||||||
|
|
||||||
|
final bool isEncrypted;
|
||||||
|
|
||||||
|
final FocusNode textfieldFocusNode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConversationBottomRowState createState() => ConversationBottomRowState();
|
ConversationInputState createState() => ConversationInputState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConversationBottomRowState extends State<ConversationBottomRow> {
|
class ConversationInputState extends State<ConversationInput> {
|
||||||
IconData _getSendButtonIcon(SendButtonState state) {
|
IconData _getSendButtonIcon(SendButtonState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
case SendButtonState.hidden:
|
||||||
case SendButtonState.multi:
|
case SendButtonState.multi:
|
||||||
return Icons.add;
|
return Icons.add;
|
||||||
case SendButtonState.send:
|
case SendButtonState.send:
|
||||||
@@ -117,314 +150,212 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getPickerIcon() {
|
|
||||||
if (widget.tabController.index == 0) {
|
|
||||||
return Icons.insert_emoticon;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PhosphorIcons.stickerBold;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return ColoredBox(
|
||||||
clipBehavior: Clip.none,
|
color: Colors.black45,
|
||||||
children: [
|
child: Padding(
|
||||||
Positioned(
|
padding: const EdgeInsets.all(8),
|
||||||
child: ColoredBox(
|
child: Stack(
|
||||||
color: Colors.transparent,
|
children: [
|
||||||
child: Column(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: StreamBuilder<TextFieldData>(
|
||||||
|
initialData: const TextFieldData(
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
stream: widget.conversationController.textFieldDataStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return CustomTextField(
|
||||||
|
backgroundColor: Theme.of(context)
|
||||||
|
.extension<MoxxyThemeData>()!
|
||||||
|
.conversationTextFieldColor,
|
||||||
|
textColor: Theme.of(context)
|
||||||
|
.extension<MoxxyThemeData>()!
|
||||||
|
.conversationTextFieldTextColor,
|
||||||
|
maxLines: 5,
|
||||||
|
hintText: t.pages.conversation.messageHint,
|
||||||
|
hintTextColor: Theme.of(context)
|
||||||
|
.extension<MoxxyThemeData>()!
|
||||||
|
.conversationTextFieldHintTextColor,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: textfieldPaddingConversation,
|
||||||
|
fontSize: textFieldFontSizeConversation,
|
||||||
|
cornerRadius: textfieldRadiusConversation,
|
||||||
|
controller:
|
||||||
|
widget.conversationController.textController,
|
||||||
|
topWidget: snapshot.data!.quotedMessage != null
|
||||||
|
? buildQuoteMessageWidget(
|
||||||
|
snapshot.data!.quotedMessage!,
|
||||||
|
isSent(
|
||||||
|
snapshot.data!.quotedMessage!,
|
||||||
|
GetIt.I.get<UIDataService>().ownJid!,
|
||||||
|
),
|
||||||
|
textfieldQuotedMessageRadius,
|
||||||
|
textfieldQuotedMessageRadius,
|
||||||
|
resetQuote:
|
||||||
|
widget.conversationController.removeQuote,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
focusNode: widget.textfieldFocusNode,
|
||||||
|
//shouldSummonKeyboard: () => !snapshot.data!.pickerVisible,
|
||||||
|
prefixIcon: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: _TextFieldIconButton(
|
||||||
|
keyboardController: widget.keyboardController,
|
||||||
|
tabController: widget.tabController,
|
||||||
|
textfieldFocusNode: widget.textfieldFocusNode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
prefixIconConstraints: const BoxConstraints(
|
||||||
|
minWidth: 24,
|
||||||
|
minHeight: 24,
|
||||||
|
),
|
||||||
|
suffixIcon: snapshot.data!.isBodyEmpty &&
|
||||||
|
snapshot.data!.quotedMessage == null
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: _TextFieldRecordButton(
|
||||||
|
conversationController:
|
||||||
|
widget.conversationController,
|
||||||
|
keyboardController: widget.keyboardController,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
suffixIconConstraints: const BoxConstraints(
|
||||||
|
minWidth: 24,
|
||||||
|
minHeight: 24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.only(left: 16),
|
||||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
child: SizedBox(
|
||||||
buildWhen: (prev, next) =>
|
width: 45,
|
||||||
prev.isRecording != next.isRecording,
|
height: 45,
|
||||||
builder: (context, state) => Row(
|
child: StreamBuilder<SendButtonState>(
|
||||||
children: [
|
initialData: defaultSendButtonState,
|
||||||
Expanded(
|
stream: widget.conversationController.sendButtonStream,
|
||||||
child: StreamBuilder<TextFieldData>(
|
builder: (context, snapshot) => IgnorePointer(
|
||||||
initialData: const TextFieldData(
|
ignoring: snapshot.data! == SendButtonState.hidden,
|
||||||
true,
|
child: AnimatedOpacity(
|
||||||
null,
|
opacity:
|
||||||
false,
|
snapshot.data! == SendButtonState.hidden ? 0 : 1,
|
||||||
),
|
duration: const Duration(milliseconds: 150),
|
||||||
stream: widget
|
child: SpeedDial(
|
||||||
.conversationController.textFieldDataStream,
|
icon: _getSendButtonIcon(snapshot.data!),
|
||||||
builder: (context, snapshot) {
|
backgroundColor: primaryColor,
|
||||||
return CustomTextField(
|
foregroundColor: Colors.white,
|
||||||
backgroundColor: Theme.of(context)
|
children: [
|
||||||
.extension<MoxxyThemeData>()!
|
SpeedDialChild(
|
||||||
.conversationTextFieldColor,
|
child: const Icon(Icons.image),
|
||||||
textColor: Theme.of(context)
|
onTap: () {
|
||||||
.extension<MoxxyThemeData>()!
|
context.read<ConversationBloc>().add(
|
||||||
.conversationTextFieldTextColor,
|
ImagePickerRequestedEvent(),
|
||||||
maxLines: 5,
|
);
|
||||||
hintText: t.pages.conversation.messageHint,
|
},
|
||||||
hintTextColor: Theme.of(context)
|
backgroundColor: primaryColor,
|
||||||
.extension<MoxxyThemeData>()!
|
foregroundColor: Colors.white,
|
||||||
.conversationTextFieldHintTextColor,
|
label: t.pages.conversation.sendImages,
|
||||||
isDense: true,
|
),
|
||||||
contentPadding: textfieldPaddingConversation,
|
SpeedDialChild(
|
||||||
fontSize: textFieldFontSizeConversation,
|
child: const Icon(Icons.file_present),
|
||||||
cornerRadius: textfieldRadiusConversation,
|
onTap: () {
|
||||||
controller: widget
|
context.read<ConversationBloc>().add(
|
||||||
.conversationController.textController,
|
FilePickerRequestedEvent(),
|
||||||
topWidget: snapshot.data!.quotedMessage != null
|
);
|
||||||
? buildQuoteMessageWidget(
|
},
|
||||||
snapshot.data!.quotedMessage!,
|
backgroundColor: primaryColor,
|
||||||
isSent(
|
foregroundColor: Colors.white,
|
||||||
snapshot.data!.quotedMessage!,
|
label: t.pages.conversation.sendFiles,
|
||||||
GetIt.I.get<UIDataService>().ownJid!,
|
),
|
||||||
),
|
SpeedDialChild(
|
||||||
resetQuote: widget
|
child: const Icon(Icons.photo_camera),
|
||||||
.conversationController.removeQuote,
|
onTap: () {
|
||||||
)
|
showNotImplementedDialog(
|
||||||
: null,
|
'taking photos',
|
||||||
focusNode: widget.focusNode,
|
context,
|
||||||
shouldSummonKeyboard: () =>
|
);
|
||||||
!snapshot.data!.pickerVisible,
|
},
|
||||||
prefixIcon: IntrinsicWidth(
|
backgroundColor: primaryColor,
|
||||||
child: Row(
|
foregroundColor: Colors.white,
|
||||||
children: [
|
label: t.pages.conversation.takePhotos,
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(left: 8),
|
],
|
||||||
child: _TextFieldIconButton(
|
openCloseDial: widget.speedDialValueNotifier,
|
||||||
snapshot.data!.pickerVisible
|
onPress: () {
|
||||||
? Icons.keyboard
|
switch (snapshot.data!) {
|
||||||
: _getPickerIcon(),
|
case SendButtonState.cancelCorrection:
|
||||||
() {
|
widget.conversationController
|
||||||
widget.conversationController
|
.endMessageEditing();
|
||||||
.togglePickerVisibility(true);
|
return;
|
||||||
},
|
case SendButtonState.send:
|
||||||
),
|
widget.conversationController.sendMessage(
|
||||||
),
|
widget.isEncrypted,
|
||||||
],
|
);
|
||||||
),
|
return;
|
||||||
),
|
case SendButtonState.multi:
|
||||||
prefixIconConstraints: const BoxConstraints(
|
widget.speedDialValueNotifier.value =
|
||||||
minWidth: 24,
|
!widget.speedDialValueNotifier.value;
|
||||||
minHeight: 24,
|
return;
|
||||||
),
|
case SendButtonState.hidden:
|
||||||
suffixIcon: snapshot.data!.isBodyEmpty &&
|
return;
|
||||||
snapshot.data!.quotedMessage == null
|
}
|
||||||
? IntrinsicWidth(
|
|
||||||
child: Row(
|
|
||||||
children: const [
|
|
||||||
Padding(
|
|
||||||
padding:
|
|
||||||
EdgeInsets.only(right: 8),
|
|
||||||
child: _TextFieldRecordButton(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
suffixIconConstraints: const BoxConstraints(
|
|
||||||
minWidth: 24,
|
|
||||||
minHeight: 24,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: state.isRecording ? 0 : 1,
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: state.isRecording,
|
|
||||||
child: SizedBox(
|
|
||||||
height: 45,
|
|
||||||
width: 45,
|
|
||||||
child: StreamBuilder<SendButtonState>(
|
|
||||||
initialData: defaultSendButtonState,
|
|
||||||
stream: widget
|
|
||||||
.conversationController.sendButtonStream,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
return SpeedDial(
|
|
||||||
icon: _getSendButtonIcon(snapshot.data!),
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
children: [
|
|
||||||
SpeedDialChild(
|
|
||||||
child: const Icon(Icons.image),
|
|
||||||
onTap: () {
|
|
||||||
context
|
|
||||||
.read<ConversationBloc>()
|
|
||||||
.add(
|
|
||||||
ImagePickerRequestedEvent(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
label:
|
|
||||||
t.pages.conversation.sendImages,
|
|
||||||
),
|
|
||||||
SpeedDialChild(
|
|
||||||
child: const Icon(Icons.file_present),
|
|
||||||
onTap: () {
|
|
||||||
context
|
|
||||||
.read<ConversationBloc>()
|
|
||||||
.add(
|
|
||||||
FilePickerRequestedEvent(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
label: t.pages.conversation.sendFiles,
|
|
||||||
),
|
|
||||||
SpeedDialChild(
|
|
||||||
child: const Icon(Icons.photo_camera),
|
|
||||||
onTap: () {
|
|
||||||
showNotImplementedDialog(
|
|
||||||
'taking photos',
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
label:
|
|
||||||
t.pages.conversation.takePhotos,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
openCloseDial:
|
|
||||||
widget.speedDialValueNotifier,
|
|
||||||
onPress: () {
|
|
||||||
switch (snapshot.data!) {
|
|
||||||
case SendButtonState.cancelCorrection:
|
|
||||||
widget.conversationController
|
|
||||||
.endMessageEditing();
|
|
||||||
return;
|
|
||||||
case SendButtonState.send:
|
|
||||||
widget.conversationController
|
|
||||||
.sendMessage(
|
|
||||||
state.conversation!.encrypted,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
case SendButtonState.multi:
|
|
||||||
widget.speedDialValueNotifier
|
|
||||||
.value =
|
|
||||||
!widget.speedDialValueNotifier
|
|
||||||
.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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(
|
||||||
),
|
top: 0,
|
||||||
Positioned(
|
bottom: 0,
|
||||||
left: 8,
|
left: 0,
|
||||||
bottom: 8,
|
right: 45 + 16,
|
||||||
right: 61,
|
child: StreamBuilder<RecordingData>(
|
||||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
initialData: const RecordingData(
|
||||||
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
|
false,
|
||||||
builder: (context, state) {
|
false,
|
||||||
return AnimatedOpacity(
|
),
|
||||||
opacity: state.isRecording ? 1 : 0,
|
stream:
|
||||||
duration: const Duration(milliseconds: 300),
|
widget.conversationController.recordingAudioMessageStream,
|
||||||
child: IgnorePointer(
|
builder: (context, snapshot) => IgnorePointer(
|
||||||
ignoring: !state.isRecording,
|
ignoring: !snapshot.data!.isRecording,
|
||||||
child: SizedBox(
|
child: AnimatedOpacity(
|
||||||
height: textFieldFontSizeConversation + 2 * 12 + 2,
|
opacity: snapshot.data!.isRecording ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.extension<MoxxyThemeData>()!
|
||||||
|
.conversationTextFieldColor,
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(textfieldRadiusConversation),
|
BorderRadius.circular(textfieldRadiusConversation),
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
),
|
),
|
||||||
// NOTE: We use a comprehension here so that the widget gets
|
child: Padding(
|
||||||
// created and destroyed to prevent the timer from running
|
padding: const EdgeInsets.only(left: 16),
|
||||||
// until the user closes the page.
|
child: Align(
|
||||||
child: state.isRecording
|
alignment: Alignment.centerLeft,
|
||||||
? const Align(
|
child: snapshot.data!.isRecording
|
||||||
alignment: Alignment.centerLeft,
|
? const TimerWidget()
|
||||||
child: Padding(
|
: const SizedBox(),
|
||||||
padding: EdgeInsets.only(left: 16),
|
),
|
||||||
child: TimerWidget(),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
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/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
|
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/avatar.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/contact_helper.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
|
|
||||||
@@ -18,12 +18,12 @@ enum ConversationOption { close, block }
|
|||||||
|
|
||||||
enum EncryptionOption { omemo, none }
|
enum EncryptionOption { omemo, none }
|
||||||
|
|
||||||
PopupMenuItem<dynamic> popupItemWithIcon(
|
PopupMenuItem<T> popupItemWithIcon<T>(
|
||||||
dynamic value,
|
T value,
|
||||||
String text,
|
String text,
|
||||||
IconData icon,
|
IconData icon,
|
||||||
) {
|
) {
|
||||||
return PopupMenuItem<dynamic>(
|
return PopupMenuItem<T>(
|
||||||
value: value,
|
value: value,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -39,26 +39,25 @@ PopupMenuItem<dynamic> popupItemWithIcon(
|
|||||||
|
|
||||||
/// A custom version of the BorderlessTopbar to display the conversation topbar
|
/// A custom version of the BorderlessTopbar to display the conversation topbar
|
||||||
/// as it should
|
/// as it should
|
||||||
// TODO(PapaTutuWawa): The conversation title may overflow the Topbar
|
|
||||||
// TODO(Unknown): Maybe merge with BorderlessTopbar
|
|
||||||
class ConversationTopbar extends StatelessWidget
|
class ConversationTopbar extends StatelessWidget
|
||||||
implements PreferredSizeWidget {
|
implements PreferredSizeWidget {
|
||||||
const ConversationTopbar({super.key});
|
const ConversationTopbar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => const Size.fromHeight(60);
|
Size get preferredSize =>
|
||||||
|
const Size.fromHeight(BorderlessTopbar.topbarPreferredHeight);
|
||||||
|
|
||||||
bool _shouldRebuild(ConversationState prev, ConversationState next) {
|
bool _shouldRebuild(ConversationState prev, ConversationState next) {
|
||||||
return prev.conversation?.title != next.conversation?.title ||
|
return prev.conversation?.title != next.conversation?.title ||
|
||||||
prev.conversation?.avatarUrl != next.conversation?.avatarUrl ||
|
prev.conversation?.avatarUrl != next.conversation?.avatarUrl ||
|
||||||
prev.conversation?.chatState != next.conversation?.chatState ||
|
prev.conversation?.chatState != next.conversation?.chatState ||
|
||||||
prev.conversation?.jid != next.conversation?.jid ||
|
prev.conversation?.jid != next.conversation?.jid ||
|
||||||
prev.conversation?.encrypted != next.conversation?.encrypted ||
|
prev.conversation?.encrypted != next.conversation?.encrypted;
|
||||||
prev.conversation?.sharedMedia != next.conversation?.sharedMedia;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChatState(ChatState state) {
|
Widget _buildChatState(ChatState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
case ChatState.composing:
|
||||||
case ChatState.paused:
|
case ChatState.paused:
|
||||||
case ChatState.active:
|
case ChatState.active:
|
||||||
return Text(
|
return Text(
|
||||||
@@ -67,12 +66,9 @@ class ConversationTopbar extends StatelessWidget
|
|||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case ChatState.composing:
|
|
||||||
// TODO(Unknown): Colors
|
|
||||||
return const TypingIndicatorWidget(Colors.black, Colors.white);
|
|
||||||
case ChatState.inactive:
|
case ChatState.inactive:
|
||||||
case ChatState.gone:
|
case ChatState.gone:
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,20 +92,20 @@ class ConversationTopbar extends StatelessWidget
|
|||||||
buildWhen: _shouldRebuild,
|
buildWhen: _shouldRebuild,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final chatState = state.conversation?.chatState ?? ChatState.gone;
|
final chatState = state.conversation?.chatState ?? ChatState.gone;
|
||||||
return SizedBox(
|
return BorderlessTopbar(
|
||||||
width: MediaQuery.of(context).size.width,
|
children: [
|
||||||
child: SafeArea(
|
Expanded(
|
||||||
child: ColoredBox(
|
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.only(
|
||||||
child: Flex(
|
top: 8,
|
||||||
direction: Axis.horizontal,
|
right: 8,
|
||||||
children: [
|
bottom: 8,
|
||||||
const BackButton(),
|
),
|
||||||
InkWell(
|
child: InkWell(
|
||||||
onTap: () => _openProfile(context, state),
|
onTap: () => _openProfile(context, state),
|
||||||
child: Hero(
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
tag: 'conversation_profile_picture',
|
tag: 'conversation_profile_picture',
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
@@ -126,139 +122,120 @@ class ConversationTopbar extends StatelessWidget
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
AnimatedPositioned(
|
||||||
Expanded(
|
duration: const Duration(milliseconds: 200),
|
||||||
child: InkWell(
|
top: _isChatStateVisible(chatState) ? 0 : 10,
|
||||||
onTap: () => _openProfile(context, state),
|
left: 60,
|
||||||
child: Stack(
|
right: 0,
|
||||||
children: [
|
curve: Curves.easeInOutCubic,
|
||||||
AnimatedPositioned(
|
child: RebuildOnContactIntegrationChange(
|
||||||
duration: const Duration(milliseconds: 200),
|
builder: () => Text(
|
||||||
top: _isChatStateVisible(chatState) ? 0 : 10,
|
state.conversation?.titleWithOptionalContact ?? '',
|
||||||
left: 0,
|
style: const TextStyle(
|
||||||
right: 0,
|
fontSize: fontsizeAppbar,
|
||||||
curve: Curves.easeInOutCubic,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
RebuildOnContactIntegrationChange(
|
|
||||||
builder: () => TopbarTitleText(
|
|
||||||
state.conversation
|
|
||||||
?.titleWithOptionalContact ??
|
|
||||||
'',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Positioned(
|
overflow: TextOverflow.ellipsis,
|
||||||
left: 0,
|
),
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity:
|
|
||||||
_isChatStateVisible(chatState) ? 1.0 : 0.0,
|
|
||||||
curve: Curves.easeInOutCubic,
|
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
_buildChatState(chatState),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
if (state.conversation?.type != ConversationType.note)
|
left: 25,
|
||||||
// ignore: implicit_dynamic_type
|
right: 0,
|
||||||
PopupMenuButton(
|
bottom: 0,
|
||||||
onSelected: (result) {
|
child: AnimatedOpacity(
|
||||||
if (result == EncryptionOption.omemo &&
|
opacity: _isChatStateVisible(chatState) ? 1.0 : 0.0,
|
||||||
state.conversation!.encrypted == false) {
|
curve: Curves.easeInOutCubic,
|
||||||
context
|
duration: const Duration(milliseconds: 100),
|
||||||
.read<ConversationBloc>()
|
child: Row(
|
||||||
.add(OmemoSetEvent(true));
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
} else if (result == EncryptionOption.none &&
|
children: [
|
||||||
state.conversation!.encrypted == true) {
|
_buildChatState(chatState),
|
||||||
context
|
],
|
||||||
.read<ConversationBloc>()
|
|
||||||
.add(OmemoSetEvent(false));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: (state.conversation?.encrypted ?? false)
|
|
||||||
? const Icon(Icons.lock)
|
|
||||||
: const Icon(Icons.lock_open),
|
|
||||||
itemBuilder: (BuildContext c) => [
|
|
||||||
popupItemWithIcon(
|
|
||||||
EncryptionOption.none,
|
|
||||||
t.pages.conversation.unencrypted,
|
|
||||||
Icons.lock_open,
|
|
||||||
),
|
),
|
||||||
popupItemWithIcon(
|
|
||||||
EncryptionOption.omemo,
|
|
||||||
t.pages.conversation.encrypted,
|
|
||||||
Icons.lock,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// ignore: implicit_dynamic_type
|
|
||||||
PopupMenuButton(
|
|
||||||
onSelected: (result) async {
|
|
||||||
switch (result) {
|
|
||||||
case ConversationOption.close:
|
|
||||||
{
|
|
||||||
final result = await showConfirmationDialog(
|
|
||||||
t.pages.conversation.closeChatConfirmTitle,
|
|
||||||
t.pages.conversation.closeChatConfirmSubtext,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
context.read<ConversationsBloc>().add(
|
|
||||||
ConversationClosedEvent(
|
|
||||||
state.conversation!.jid,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Navigate back
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
context.read<NavigationBloc>().add(
|
|
||||||
PoppedRouteEvent(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case ConversationOption.block:
|
|
||||||
{
|
|
||||||
await blockJid(state.conversation!.jid, context);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.more_vert),
|
|
||||||
itemBuilder: (BuildContext c) => [
|
|
||||||
popupItemWithIcon(
|
|
||||||
ConversationOption.close,
|
|
||||||
t.pages.conversation.closeChat,
|
|
||||||
Icons.close,
|
|
||||||
),
|
),
|
||||||
if (state.conversation?.type != ConversationType.note)
|
),
|
||||||
popupItemWithIcon(
|
],
|
||||||
ConversationOption.block,
|
),
|
||||||
t.pages.conversation.blockUser,
|
|
||||||
Icons.block,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (state.conversation?.type != ConversationType.note)
|
||||||
|
PopupMenuButton<EncryptionOption>(
|
||||||
|
onSelected: (result) {
|
||||||
|
if (result == EncryptionOption.omemo &&
|
||||||
|
state.conversation!.encrypted == false) {
|
||||||
|
context.read<ConversationBloc>().add(OmemoSetEvent(true));
|
||||||
|
} else if (result == EncryptionOption.none &&
|
||||||
|
state.conversation!.encrypted == true) {
|
||||||
|
context.read<ConversationBloc>().add(OmemoSetEvent(false));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: (state.conversation?.encrypted ?? false)
|
||||||
|
? const Icon(Icons.lock)
|
||||||
|
: const Icon(Icons.lock_open),
|
||||||
|
itemBuilder: (BuildContext c) => [
|
||||||
|
popupItemWithIcon<EncryptionOption>(
|
||||||
|
EncryptionOption.none,
|
||||||
|
t.pages.conversation.unencrypted,
|
||||||
|
Icons.lock_open,
|
||||||
|
),
|
||||||
|
popupItemWithIcon<EncryptionOption>(
|
||||||
|
EncryptionOption.omemo,
|
||||||
|
t.pages.conversation.encrypted,
|
||||||
|
Icons.lock,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
PopupMenuButton<ConversationOption>(
|
||||||
|
onSelected: (result) async {
|
||||||
|
switch (result) {
|
||||||
|
case ConversationOption.close:
|
||||||
|
{
|
||||||
|
final result = await showConfirmationDialog(
|
||||||
|
t.pages.conversation.closeChatConfirmTitle,
|
||||||
|
t.pages.conversation.closeChatConfirmSubtext,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context.read<ConversationsBloc>().add(
|
||||||
|
ConversationClosedEvent(
|
||||||
|
state.conversation!.jid,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate back
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context.read<NavigationBloc>().add(
|
||||||
|
PoppedRouteEvent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ConversationOption.block:
|
||||||
|
{
|
||||||
|
await blockJid(state.conversation!.jid, context);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
itemBuilder: (BuildContext c) => [
|
||||||
|
popupItemWithIcon<ConversationOption>(
|
||||||
|
ConversationOption.close,
|
||||||
|
t.pages.conversation.closeChat,
|
||||||
|
Icons.close,
|
||||||
|
),
|
||||||
|
if (state.conversation?.type != ConversationType.note)
|
||||||
|
popupItemWithIcon<ConversationOption>(
|
||||||
|
ConversationOption.block,
|
||||||
|
t.pages.conversation.blockUser,
|
||||||
|
Icons.block,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
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/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/avatar.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/conversation.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
|
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
|
|
||||||
enum ConversationsOptions { settings }
|
enum ConversationsOptions { settings }
|
||||||
@@ -91,38 +91,77 @@ class ConversationsPage extends StatefulWidget {
|
|||||||
|
|
||||||
class ConversationsPageState extends State<ConversationsPage>
|
class ConversationsPageState extends State<ConversationsPage>
|
||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late final AnimationController _controller;
|
/// The JID of the currently selected conversation.
|
||||||
late Animation<double> _convY;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
_contextMenuController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
_contextMenuAnimation = Tween<double>(
|
||||||
|
begin: 0,
|
||||||
|
end: 1,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _contextMenuController,
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_contextMenuController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _listWrapper(BuildContext context, ConversationsState state) {
|
void dismissContextMenu() {
|
||||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
_contextMenuController.reverse();
|
||||||
|
setState(() {
|
||||||
|
_selectedConversation = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _listWrapper(BuildContext context, ConversationsState state) {
|
||||||
if (state.conversations.isNotEmpty) {
|
if (state.conversations.isNotEmpty) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: state.conversations.length,
|
itemCount: state.conversations.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = state.conversations[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(
|
final row = ConversationsListRow(
|
||||||
maxTextWidth,
|
|
||||||
item,
|
item,
|
||||||
true,
|
true,
|
||||||
enableAvatarOnTap: 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(
|
return ConversationsRowDismissible(
|
||||||
@@ -131,79 +170,40 @@ class ConversationsPageState extends State<ConversationsPage>
|
|||||||
onLongPressStart: (event) async {
|
onLongPressStart: (event) async {
|
||||||
Vibrate.feedback(FeedbackType.medium);
|
Vibrate.feedback(FeedbackType.medium);
|
||||||
|
|
||||||
_convY = Tween<double>(
|
final widgetRect = getWidgetPositionOnScreen(key);
|
||||||
begin: event.globalPosition.dy - 20,
|
final height = MediaQuery.of(context).size.height;
|
||||||
end: 200,
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.easeInOutCubic,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _controller.forward();
|
setState(() {
|
||||||
|
_selectedConversation = item;
|
||||||
|
|
||||||
// ignore: use_build_context_synchronously
|
final numberOptions = item.numberContextMenuOptions;
|
||||||
await showDialog<void>(
|
if (height - widgetRect.bottom >
|
||||||
context: context,
|
40 + numberOptions * ContextMenuItem.height) {
|
||||||
builder: (context) => OverviewMenu(
|
// In this case, we have enough space below the conversation item,
|
||||||
_convY,
|
// so we say that the top of the context menu is
|
||||||
highlight: row,
|
// widgetRect.bottom (Bottom y coordinate of the conversation item)
|
||||||
left: 0,
|
// minus 20 (padding so we're not directly against the conversation
|
||||||
right: 0,
|
// item) - the height of the top bar.
|
||||||
children: [
|
_topStackOffset = widgetRect.bottom -
|
||||||
if (item.unreadCounter != 0)
|
20 -
|
||||||
OverviewMenuItem(
|
BorderlessTopbar.topbarPreferredHeight;
|
||||||
icon: Icons.done_all,
|
} else {
|
||||||
text: t.pages.conversations.markAsRead,
|
// In this case we don't have sufficient space below the conversation
|
||||||
onPressed: () {
|
// item, so we place the context menu above it.
|
||||||
context.read<ConversationsBloc>().add(
|
// The computation is the same as in the above branch, but now
|
||||||
ConversationMarkedAsReadEvent(item.jid),
|
// we position the context menu above and thus also substract the
|
||||||
);
|
// height of the context menu
|
||||||
Navigator.of(context).pop();
|
// (numberOptions * ContextMenuItem.height).
|
||||||
},
|
_topStackOffset = widgetRect.top -
|
||||||
),
|
20 -
|
||||||
OverviewMenuItem(
|
numberOptions * ContextMenuItem.height -
|
||||||
icon: Icons.close,
|
BorderlessTopbar.topbarPreferredHeight;
|
||||||
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) {
|
await _contextMenuController.forward();
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _controller.reverse();
|
|
||||||
},
|
},
|
||||||
child: InkWell(
|
child: row,
|
||||||
onTap: () => GetIt.I.get<ConversationBloc>().add(
|
|
||||||
RequestedConversationEvent(
|
|
||||||
item.jid,
|
|
||||||
item.title,
|
|
||||||
item.avatarUrl,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: row,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -236,31 +236,59 @@ class ConversationsPageState extends State<ConversationsPage>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<ConversationsBloc, ConversationsState>(
|
return BlocBuilder<ConversationsBloc, ConversationsState>(
|
||||||
builder: (BuildContext context, ConversationsState state) => Scaffold(
|
builder: (BuildContext context, ConversationsState state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.avatarAndName(
|
appBar: BorderlessTopbar(
|
||||||
TopbarAvatarAndName(
|
showBackButton: false,
|
||||||
TopbarTitleText(state.displayName),
|
children: [
|
||||||
Hero(
|
Expanded(
|
||||||
tag: 'self_profile_picture',
|
child: InkWell(
|
||||||
child: Material(
|
onTap: () {
|
||||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
// Dismiss the selection, if we have an active one
|
||||||
child: AvatarWrapper(
|
if (_selectedConversation != null) {
|
||||||
radius: 20,
|
dismissContextMenu();
|
||||||
avatarUrl: state.avatarUrl,
|
}
|
||||||
altIcon: Icons.person,
|
|
||||||
|
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(
|
||||||
|
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||||
|
child: AvatarWrapper(
|
||||||
|
radius: 20,
|
||||||
|
avatarUrl: state.avatarUrl,
|
||||||
|
altIcon: Icons.person,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
state.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: fontsizeAppbar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
() => GetIt.I.get<profile.ProfileBloc>().add(
|
Padding(
|
||||||
profile.ProfilePageRequestedEvent(
|
padding: const EdgeInsets.all(8),
|
||||||
true,
|
child: PopupMenuButton(
|
||||||
jid: state.jid,
|
|
||||||
avatarUrl: state.avatarUrl,
|
|
||||||
displayName: state.displayName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
showBackButton: false,
|
|
||||||
extra: [
|
|
||||||
PopupMenuButton(
|
|
||||||
onSelected: (ConversationsOptions result) {
|
onSelected: (ConversationsOptions result) {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case ConversationsOptions.settings:
|
case ConversationsOptions.settings:
|
||||||
@@ -275,11 +303,86 @@ class ConversationsPageState extends State<ConversationsPage>
|
|||||||
child: Text(t.pages.conversations.overlaySettings),
|
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(
|
floatingActionButton: SpeedDial(
|
||||||
icon: Icons.chat,
|
icon: Icons.chat,
|
||||||
curve: Curves.bounceInOut,
|
curve: Curves.bounceInOut,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class Login extends StatelessWidget {
|
|||||||
builder: (BuildContext context, LoginState state) => WillPopScope(
|
builder: (BuildContext context, LoginState state) => WillPopScope(
|
||||||
onWillPop: () async => !state.working,
|
onWillPop: () async => !state.working,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.login.title),
|
appBar: BorderlessTopbar.title(t.pages.login.title),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
Visibility(
|
||||||
|
|||||||
@@ -20,38 +20,45 @@ class NewConversationPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _renderIconEntry(IconData icon, String text, void Function() onTap) {
|
Widget _renderIconEntry(IconData icon, String text, VoidCallback onTap) {
|
||||||
return InkWell(
|
return Padding(
|
||||||
onTap: onTap,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Row(
|
child: ClipRRect(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(radiusLargeSize),
|
||||||
Padding(
|
child: Material(
|
||||||
padding: const EdgeInsets.all(8),
|
child: InkWell(
|
||||||
child: AvatarWrapper(
|
onTap: onTap,
|
||||||
radius: 35,
|
child: Row(
|
||||||
altIcon: icon,
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: AvatarWrapper(
|
||||||
|
radius: 35,
|
||||||
|
altIcon: icon,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.newconversation.title),
|
appBar: BorderlessTopbar.title(t.pages.newconversation.title),
|
||||||
body: BlocBuilder<NewConversationBloc, NewConversationState>(
|
body: BlocBuilder<NewConversationBloc, NewConversationState>(
|
||||||
builder: (BuildContext context, NewConversationState state) =>
|
builder: (BuildContext context, NewConversationState state) =>
|
||||||
ListView.builder(
|
ListView.builder(
|
||||||
@@ -87,8 +94,39 @@ class NewConversationPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: ConversationsListRow(
|
||||||
onTap: () => context.read<NewConversationBloc>().add(
|
Conversation(
|
||||||
|
item.title,
|
||||||
|
Message(
|
||||||
|
'',
|
||||||
|
item.jid,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
item.avatarUrl,
|
||||||
|
item.jid,
|
||||||
|
0,
|
||||||
|
ConversationType.chat,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChatState.gone,
|
||||||
|
contactId: item.contactId,
|
||||||
|
contactAvatarPath: item.contactAvatarPath,
|
||||||
|
contactDisplayName: item.contactDisplayName,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
showTimestamp: false,
|
||||||
|
isSelected: false,
|
||||||
|
onPressed: () => context.read<NewConversationBloc>().add(
|
||||||
NewConversationAddedEvent(
|
NewConversationAddedEvent(
|
||||||
item.jid,
|
item.jid,
|
||||||
item.title,
|
item.title,
|
||||||
@@ -96,44 +134,8 @@ class NewConversationPage extends StatelessWidget {
|
|||||||
ConversationType.chat,
|
ConversationType.chat,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ConversationsListRow(
|
titleSuffixIcon:
|
||||||
maxTextWidth,
|
item.pseudoRosterItem ? Icons.smartphone : null,
|
||||||
Conversation(
|
|
||||||
item.title,
|
|
||||||
Message(
|
|
||||||
'',
|
|
||||||
item.jid,
|
|
||||||
0,
|
|
||||||
'',
|
|
||||||
0,
|
|
||||||
'',
|
|
||||||
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,
|
|
||||||
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';
|
//import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
|
|
||||||
class ConversationProfileHeader extends StatelessWidget {
|
class ConversationProfileHeader extends StatelessWidget {
|
||||||
const ConversationProfileHeader(this.conversation, {super.key});
|
const ConversationProfileHeader({super.key});
|
||||||
final Conversation conversation;
|
|
||||||
|
|
||||||
Future<void> _showAvatarFullsize(BuildContext context, String path) async {
|
Future<void> _showAvatarFullsize(BuildContext context, String path) async {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
@@ -25,7 +24,7 @@ class ConversationProfileHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAvatar(BuildContext context) {
|
Widget _buildAvatar(BuildContext context, Conversation conversation) {
|
||||||
return RebuildOnContactIntegrationChange(
|
return RebuildOnContactIntegrationChange(
|
||||||
builder: () {
|
builder: () {
|
||||||
final path = conversation.avatarPathWithOptionalContact;
|
final path = conversation.avatarPathWithOptionalContact;
|
||||||
@@ -49,77 +48,83 @@ class ConversationProfileHeader extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return BlocBuilder<ProfileBloc, ProfileState>(
|
||||||
children: [
|
builder: (context, state) {
|
||||||
Hero(
|
final conversation = state.conversation!;
|
||||||
tag: 'conversation_profile_picture',
|
return Column(
|
||||||
child: Material(
|
children: [
|
||||||
child: _buildAvatar(
|
Hero(
|
||||||
context,
|
tag: 'conversation_profile_picture',
|
||||||
),
|
child: Material(
|
||||||
),
|
child: _buildAvatar(
|
||||||
),
|
context,
|
||||||
Padding(
|
conversation,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
),
|
||||||
child: RebuildOnContactIntegrationChange(
|
|
||||||
builder: () => Text(
|
|
||||||
conversation.titleWithOptionalContact,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 30,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
Padding(
|
child: RebuildOnContactIntegrationChange(
|
||||||
padding: const EdgeInsets.only(top: 3),
|
builder: () => Text(
|
||||||
child: Text(
|
conversation.titleWithOptionalContact,
|
||||||
conversation.jid,
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
fontSize: 30,
|
||||||
fontSize: 15,
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
top: 16,
|
|
||||||
left: 64,
|
|
||||||
right: 64,
|
|
||||||
),
|
|
||||||
child: ProfileOptions(
|
|
||||||
options: [
|
|
||||||
ProfileOption(
|
|
||||||
icon: Icons.security_outlined,
|
|
||||||
title: t.pages.profile.general.omemo,
|
|
||||||
onTap: () {
|
|
||||||
context.read<DevicesBloc>().add(
|
|
||||||
DevicesRequestedEvent(conversation.jid),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
ProfileOption(
|
),
|
||||||
icon: conversation.muted
|
|
||||||
? Icons.notifications_off
|
|
||||||
: Icons.notifications,
|
|
||||||
title: t.pages.profile.conversation.notifications,
|
|
||||||
description: conversation.muted
|
|
||||||
? t.pages.profile.conversation.notificationsMuted
|
|
||||||
: t.pages.profile.conversation.notificationsEnabled,
|
|
||||||
onTap: () {
|
|
||||||
context.read<ProfileBloc>().add(
|
|
||||||
MuteStateSetEvent(
|
|
||||||
conversation.jid,
|
|
||||||
!conversation.muted,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.only(top: 3),
|
||||||
],
|
child: Text(
|
||||||
|
conversation.jid,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 16,
|
||||||
|
left: 64,
|
||||||
|
right: 64,
|
||||||
|
),
|
||||||
|
child: ProfileOptions(
|
||||||
|
options: [
|
||||||
|
ProfileOption(
|
||||||
|
icon: Icons.security_outlined,
|
||||||
|
title: t.pages.profile.general.omemo,
|
||||||
|
onTap: () {
|
||||||
|
context.read<DevicesBloc>().add(
|
||||||
|
DevicesRequestedEvent(conversation.jid),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ProfileOption(
|
||||||
|
icon: conversation.muted
|
||||||
|
? Icons.notifications_off
|
||||||
|
: Icons.notifications,
|
||||||
|
title: t.pages.profile.conversation.notifications,
|
||||||
|
description: conversation.muted
|
||||||
|
? t.pages.profile.conversation.notificationsMuted
|
||||||
|
: t.pages.profile.conversation.notificationsEnabled,
|
||||||
|
onTap: () {
|
||||||
|
context.read<ProfileBloc>().add(
|
||||||
|
MuteStateSetEvent(
|
||||||
|
conversation.jid,
|
||||||
|
!conversation.muted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,26 +100,23 @@ class DevicesPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<DevicesBloc, DevicesState>(
|
return BlocBuilder<DevicesBloc, DevicesState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(
|
appBar: BorderlessTopbar.title(
|
||||||
t.pages.profile.devices.title,
|
t.pages.profile.devices.title,
|
||||||
extra: [
|
trailing: PopupMenuButton(
|
||||||
const Spacer(),
|
onSelected: (DevicesOptions result) {
|
||||||
PopupMenuButton(
|
if (result == DevicesOptions.recreateSessions) {
|
||||||
onSelected: (DevicesOptions result) {
|
_recreateSessions(context);
|
||||||
if (result == DevicesOptions.recreateSessions) {
|
}
|
||||||
_recreateSessions(context);
|
},
|
||||||
}
|
icon: const Icon(Icons.more_vert),
|
||||||
},
|
itemBuilder: (BuildContext context) => [
|
||||||
icon: const Icon(Icons.more_vert),
|
PopupMenuItem(
|
||||||
itemBuilder: (BuildContext context) => [
|
value: DevicesOptions.recreateSessions,
|
||||||
PopupMenuItem(
|
enabled: state.devices.isNotEmpty,
|
||||||
value: DevicesOptions.recreateSessions,
|
child: Text(t.pages.profile.devices.recreateSessions),
|
||||||
enabled: state.devices.isNotEmpty,
|
)
|
||||||
child: Text(t.pages.profile.devices.recreateSessions),
|
],
|
||||||
)
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: _buildBody(context, state),
|
body: _buildBody(context, state),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -180,35 +180,32 @@ class OwnDevicesPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<OwnDevicesBloc, OwnDevicesState>(
|
return BlocBuilder<OwnDevicesBloc, OwnDevicesState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(
|
appBar: BorderlessTopbar.title(
|
||||||
t.pages.profile.owndevices.title,
|
t.pages.profile.owndevices.title,
|
||||||
extra: [
|
trailing: PopupMenuButton(
|
||||||
const Spacer(),
|
onSelected: (OwnDevicesOptions result) {
|
||||||
PopupMenuButton(
|
switch (result) {
|
||||||
onSelected: (OwnDevicesOptions result) {
|
case OwnDevicesOptions.recreateSessions:
|
||||||
switch (result) {
|
_recreateSessions(context);
|
||||||
case OwnDevicesOptions.recreateSessions:
|
break;
|
||||||
_recreateSessions(context);
|
case OwnDevicesOptions.recreateDevice:
|
||||||
break;
|
_recreateDevice(context);
|
||||||
case OwnDevicesOptions.recreateDevice:
|
break;
|
||||||
_recreateDevice(context);
|
}
|
||||||
break;
|
},
|
||||||
}
|
icon: const Icon(Icons.more_vert),
|
||||||
},
|
itemBuilder: (BuildContext context) => [
|
||||||
icon: const Icon(Icons.more_vert),
|
PopupMenuItem(
|
||||||
itemBuilder: (BuildContext context) => [
|
value: OwnDevicesOptions.recreateSessions,
|
||||||
PopupMenuItem(
|
enabled: state.keys.isNotEmpty,
|
||||||
value: OwnDevicesOptions.recreateSessions,
|
child: Text(t.pages.profile.owndevices.recreateOwnSessions),
|
||||||
enabled: state.keys.isNotEmpty,
|
),
|
||||||
child: Text(t.pages.profile.owndevices.recreateOwnSessions),
|
PopupMenuItem(
|
||||||
),
|
value: OwnDevicesOptions.recreateDevice,
|
||||||
PopupMenuItem(
|
child: Text(t.pages.profile.owndevices.recreateOwnDevice),
|
||||||
value: OwnDevicesOptions.recreateDevice,
|
),
|
||||||
child: Text(t.pages.profile.owndevices.recreateOwnDevice),
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: _buildBody(context, state),
|
body: _buildBody(context, state),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,90 +1,137 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/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/constants.dart';
|
||||||
import 'package:moxxyv2/ui/pages/profile/conversationheader.dart';
|
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
|
||||||
import 'package:moxxyv2/ui/pages/profile/selfheader.dart';
|
import 'package:moxxyv2/ui/pages/profile/profile_view.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
|
import 'package:moxxyv2/ui/pages/profile/shared_media_view.dart';
|
||||||
|
|
||||||
class ProfilePage extends StatelessWidget {
|
class ProfileArguments {
|
||||||
const ProfilePage({super.key});
|
ProfileArguments(this.isSelfProfile, this.jid);
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
bool isSelfProfile;
|
||||||
builder: (_) => const ProfilePage(),
|
|
||||||
|
/// 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(
|
settings: const RouteSettings(
|
||||||
name: profileRoute,
|
name: profileRoute,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context, ProfileState state) {
|
@override
|
||||||
if (state.isSelfProfile) {
|
ProfilePageState createState() => ProfilePageState();
|
||||||
return SelfProfileHeader(
|
}
|
||||||
state.jid,
|
|
||||||
state.avatarUrl,
|
|
||||||
state.displayName,
|
|
||||||
(path, hash) => context.read<ProfileBloc>().add(
|
|
||||||
AvatarSetEvent(path, hash),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConversationProfileHeader(state.conversation!);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Scaffold(
|
child: Stack(
|
||||||
body: BlocBuilder<ProfileBloc, ProfileState>(
|
children: [
|
||||||
builder: (context, state) => Stack(
|
Scaffold(
|
||||||
alignment: Alignment.center,
|
bottomNavigationBar: !widget.arguments.isSelfProfile
|
||||||
children: [
|
? BottomNavigationBar(
|
||||||
ListView(
|
currentIndex: _pageIndex,
|
||||||
children: [
|
onTap: (index) {
|
||||||
Padding(
|
_pageController.animateToPage(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
index,
|
||||||
child: _buildHeader(context, state),
|
duration: const Duration(milliseconds: 300),
|
||||||
),
|
curve: Curves.easeInOutQuint,
|
||||||
if (!state.isSelfProfile &&
|
);
|
||||||
state.conversation!.sharedMedia.isNotEmpty)
|
setState(() {
|
||||||
SharedMediaDisplay(
|
_pageIndex = index;
|
||||||
preview: state.conversation!.sharedMedia,
|
});
|
||||||
jid: state.conversation!.jid,
|
|
||||||
title: state.conversation!.titleWithOptionalContact,
|
|
||||||
sharedMediaAmount: state.conversation!.sharedMediaAmount,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 8,
|
|
||||||
left: 8,
|
|
||||||
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());
|
|
||||||
},
|
},
|
||||||
),
|
items: [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: const Icon(Icons.person),
|
||||||
|
label: t.pages.profile.general.profile,
|
||||||
|
),
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,105 +2,108 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/own_devices_bloc.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/helpers.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/profile/profile.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/profile/options.dart';
|
import 'package:moxxyv2/ui/widgets/profile/options.dart';
|
||||||
|
|
||||||
class SelfProfileHeader extends StatelessWidget {
|
class SelfProfileHeader extends StatelessWidget {
|
||||||
const SelfProfileHeader(
|
const SelfProfileHeader(this.arguments, {super.key});
|
||||||
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;
|
|
||||||
|
|
||||||
Future<void> pickAndSetAvatar(BuildContext context) async {
|
final ProfileArguments arguments;
|
||||||
final avatar = await pickAvatar(context, jid, avatarUrl);
|
|
||||||
|
Future<void> pickAndSetAvatar(BuildContext context, String avatarUrl) async {
|
||||||
|
final avatar = await pickAvatar(context, arguments.jid, avatarUrl);
|
||||||
|
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
setAvatar(avatar.path, avatar.hash);
|
// ignore: use_build_context_synchronously
|
||||||
|
context.read<ProfileBloc>().add(
|
||||||
|
AvatarSetEvent(avatar.path, avatar.hash),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return BlocBuilder<ProfileBloc, ProfileState>(
|
||||||
children: [
|
builder: (context, state) {
|
||||||
Hero(
|
return Column(
|
||||||
tag: 'self_profile_picture',
|
children: [
|
||||||
child: Material(
|
Hero(
|
||||||
child: AvatarWrapper(
|
tag: 'self_profile_picture',
|
||||||
radius: 110,
|
child: Material(
|
||||||
avatarUrl: avatarUrl,
|
child: AvatarWrapper(
|
||||||
altIcon: Icons.person,
|
radius: 110,
|
||||||
onTapFunction: () => pickAndSetAvatar(context),
|
avatarUrl: state.avatarUrl,
|
||||||
),
|
altIcon: Icons.person,
|
||||||
),
|
onTapFunction: () =>
|
||||||
),
|
pickAndSetAvatar(context, state.avatarUrl),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
displayName,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 3),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
jid,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsetsDirectional.only(start: 3),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.qr_code),
|
|
||||||
onPressed: () => showQrCode(context, 'xmpp:$jid'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
top: 16,
|
|
||||||
left: 64,
|
|
||||||
right: 64,
|
|
||||||
),
|
),
|
||||||
child: ProfileOptions(
|
Padding(
|
||||||
options: [
|
padding: const EdgeInsets.only(top: 8),
|
||||||
ProfileOption(
|
child: Row(
|
||||||
icon: Icons.security_outlined,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
title: t.pages.profile.general.omemo,
|
children: [
|
||||||
onTap: () {
|
Text(
|
||||||
context.read<OwnDevicesBloc>().add(
|
state.displayName,
|
||||||
OwnDevicesRequestedEvent(),
|
style: const TextStyle(
|
||||||
);
|
fontSize: 20,
|
||||||
},
|
),
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
),
|
padding: const EdgeInsets.only(top: 3),
|
||||||
],
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
arguments.jid,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(start: 3),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.qr_code),
|
||||||
|
onPressed: () =>
|
||||||
|
showQrCode(context, 'xmpp:${arguments.jid}'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 16,
|
||||||
|
left: 64,
|
||||||
|
right: 64,
|
||||||
|
),
|
||||||
|
child: ProfileOptions(
|
||||||
|
options: [
|
||||||
|
ProfileOption(
|
||||||
|
icon: Icons.security_outlined,
|
||||||
|
title: t.pages.profile.general.omemo,
|
||||||
|
onTap: () {
|
||||||
|
context.read<OwnDevicesBloc>().add(
|
||||||
|
OwnDevicesRequestedEvent(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Server Information'),
|
// TODO(PapaTutuWawa): Translate
|
||||||
|
appBar: BorderlessTopbar.title('Server Information'),
|
||||||
body: BlocBuilder<ServerInfoBloc, ServerInfoState>(
|
body: BlocBuilder<ServerInfoBloc, ServerInfoState>(
|
||||||
builder: (BuildContext context, ServerInfoState state) {
|
builder: (BuildContext context, ServerInfoState state) {
|
||||||
if (state.working) {
|
if (state.working) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class SettingsAboutPageState extends State<SettingsAboutPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.about.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.about.title),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class AppearanceSettingsPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.appearance.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.appearance.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => ListView(
|
builder: (context, state) => ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ConversationSettingsPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.conversation.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.conversation.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => ListView(
|
builder: (context, state) => ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.debugging.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.debugging.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => ListView(
|
builder: (context, state) => ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class SettingsLicensesPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.licenses.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.licenses.title),
|
||||||
body: ListView.builder(
|
body: ListView.builder(
|
||||||
itemCount: usedLibraryList.length,
|
itemCount: usedLibraryList.length,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class NetworkPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.network.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.network.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => ListView(
|
builder: (context, state) => ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.privacy.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.privacy.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => ListView(
|
builder: (context, state) => ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SettingsPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.settings.settings.title),
|
appBar: BorderlessTopbar.title(t.pages.settings.settings.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||||
builder: (context, state) => ListView(
|
builder: (context, state) => ListView(
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ class StickersSettingsPage extends StatelessWidget {
|
|||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar:
|
appBar: BorderlessTopbar.title(t.pages.settings.stickers.title),
|
||||||
BorderlessTopbar.simple(t.pages.settings.stickers.title),
|
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (_, prefs) => Padding(
|
builder: (_, prefs) => Padding(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ class ShareSelectionPage extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
|
||||||
|
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
GetIt.I.get<ShareSelectionBloc>().add(ResetEvent());
|
GetIt.I.get<ShareSelectionBloc>().add(ResetEvent());
|
||||||
@@ -67,54 +65,40 @@ class ShareSelectionPage extends StatelessWidget {
|
|||||||
child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>(
|
child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>(
|
||||||
buildWhen: _buildWhen,
|
buildWhen: _buildWhen,
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.shareselection.shareWith),
|
appBar: BorderlessTopbar.title(t.pages.shareselection.shareWith),
|
||||||
body: ListView.builder(
|
body: ListView.builder(
|
||||||
itemCount: state.items.length,
|
itemCount: state.items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = state.items[index];
|
final item = state.items[index];
|
||||||
final isSelected = state.selection.contains(index);
|
|
||||||
|
|
||||||
return InkWell(
|
return ConversationsListRow(
|
||||||
onTap: () {
|
Conversation(
|
||||||
|
item.title,
|
||||||
|
null,
|
||||||
|
item.avatarPath,
|
||||||
|
item.jid,
|
||||||
|
0,
|
||||||
|
ConversationType.chat,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChatState.gone,
|
||||||
|
contactId: item.contactId,
|
||||||
|
contactAvatarPath: item.contactAvatarPath,
|
||||||
|
contactDisplayName: item.contactDisplayName,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
titleSuffixIcon: _getSuffixIcon(item),
|
||||||
|
showTimestamp: false,
|
||||||
|
isSelected: state.selection.contains(index),
|
||||||
|
onPressed: () {
|
||||||
context.read<ShareSelectionBloc>().add(
|
context.read<ShareSelectionBloc>().add(
|
||||||
SelectionToggledEvent(index),
|
SelectionToggledEvent(index),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: ConversationsListRow(
|
|
||||||
maxTextWidth,
|
|
||||||
Conversation(
|
|
||||||
item.title,
|
|
||||||
null,
|
|
||||||
item.avatarPath,
|
|
||||||
item.jid,
|
|
||||||
0,
|
|
||||||
ConversationType.chat,
|
|
||||||
0,
|
|
||||||
[],
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
'',
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
ChatState.gone,
|
|
||||||
0,
|
|
||||||
contactId: item.contactId,
|
|
||||||
contactAvatarPath: item.contactAvatarPath,
|
|
||||||
contactDisplayName: item.contactDisplayName,
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
titleSuffixIcon: _getSuffixIcon(item),
|
|
||||||
showTimestamp: false,
|
|
||||||
extraWidgetWidth: 48,
|
|
||||||
extra: Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (_) {
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (sticker.path.isNotEmpty) {
|
if (sticker.fileMetadata.path != null) {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
File(sticker.path),
|
File(sticker.fileMetadata.path!),
|
||||||
fit: cover ? BoxFit.contain : null,
|
fit: cover ? BoxFit.contain : null,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Image.network(
|
return Image.network(
|
||||||
sticker.urlSources.first,
|
sticker.fileMetadata.sourceUrls!.first,
|
||||||
fit: cover ? BoxFit.contain : null,
|
fit: cover ? BoxFit.contain : null,
|
||||||
loadingBuilder: (_, child, event) {
|
loadingBuilder: (_, child, event) {
|
||||||
if (event == null) return child;
|
if (event == null) return child;
|
||||||
@@ -214,9 +214,7 @@ class StickerPackPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<StickerPackBloc, StickerPackState>(
|
return BlocBuilder<StickerPackBloc, StickerPackState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(
|
appBar: BorderlessTopbar.title(state.stickerPack?.name ?? '...'),
|
||||||
state.stickerPack?.name ?? '...',
|
|
||||||
),
|
|
||||||
body: state.isWorking
|
body: state.isWorking
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
width: MediaQuery.of(context).size.width,
|
width: MediaQuery.of(context).size.width,
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 3),
|
padding: const EdgeInsets.only(top: 3),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.message.isMedia && widget.message.mediaSize != null
|
widget.message.isMedia && widget.message.fileMetadata!.size != null
|
||||||
? '${fileSizeToString(widget.message.mediaSize!)} • $_timestampString'
|
? '${fileSizeToString(widget.message.fileMetadata!.size!)} • $_timestampString'
|
||||||
: _timestampString,
|
: _timestampString,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: fontsizeSubbody,
|
fontSize: fontsizeSubbody,
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.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/constants.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/message.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';
|
import 'package:swipeable_tile/swipeable_tile.dart';
|
||||||
|
|
||||||
class RawChatBubble extends StatelessWidget {
|
class RawChatBubble extends StatelessWidget {
|
||||||
@@ -53,8 +52,9 @@ class RawChatBubble extends StatelessWidget {
|
|||||||
/// Specified when the message bubble should not have color
|
/// Specified when the message bubble should not have color
|
||||||
bool _shouldNotColorBubble() {
|
bool _shouldNotColorBubble() {
|
||||||
var isInlinedWidget = false;
|
var isInlinedWidget = false;
|
||||||
if (message.mediaType != null) {
|
if (message.isMedia) {
|
||||||
isInlinedWidget = message.mediaType!.startsWith('image/');
|
isInlinedWidget =
|
||||||
|
message.fileMetadata!.mimeType?.startsWith('image/') ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it is a pseudo message
|
// Check if it is a pseudo message
|
||||||
@@ -63,12 +63,14 @@ class RawChatBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if it is an embedded file
|
// Check if it is an embedded file
|
||||||
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) {
|
if (message.isMedia &&
|
||||||
|
message.fileMetadata!.path != null &&
|
||||||
|
isInlinedWidget) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stickers are also not colored
|
// Stickers are also not colored
|
||||||
return message.stickerPackId != null && message.stickerHashKey != null;
|
return message.stickerPackId != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getBubbleColor(BuildContext context) {
|
Color _getBubbleColor(BuildContext context) {
|
||||||
@@ -114,6 +116,8 @@ class RawChatBubble extends StatelessWidget {
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
borderRadius,
|
borderRadius,
|
||||||
sentBySelf,
|
sentBySelf,
|
||||||
|
borderRadius.topLeft.x,
|
||||||
|
borderRadius.topRight.x,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -129,7 +133,6 @@ class ChatBubble extends StatefulWidget {
|
|||||||
required this.onSwipedCallback,
|
required this.onSwipedCallback,
|
||||||
required this.bubble,
|
required this.bubble,
|
||||||
this.onLongPressed,
|
this.onLongPressed,
|
||||||
this.onReactionTap,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final Message message;
|
final Message message;
|
||||||
@@ -142,8 +145,6 @@ class ChatBubble extends StatefulWidget {
|
|||||||
final GestureLongPressStartCallback? onLongPressed;
|
final GestureLongPressStartCallback? onLongPressed;
|
||||||
// The actual message bubble
|
// The actual message bubble
|
||||||
final RawChatBubble bubble;
|
final RawChatBubble bubble;
|
||||||
// For acting on reaction taps
|
|
||||||
final void Function(Reaction)? onReactionTap;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ChatBubbleState createState() => ChatBubbleState();
|
ChatBubbleState createState() => ChatBubbleState();
|
||||||
@@ -165,33 +166,6 @@ class ChatBubbleState extends State<ChatBubble>
|
|||||||
: SwipeDirection.startToEnd;
|
: 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -267,17 +241,36 @@ class ChatBubbleState extends State<ChatBubble>
|
|||||||
child: Align(
|
child: Align(
|
||||||
alignment:
|
alignment:
|
||||||
widget.sentBySelf ? Alignment.centerRight : Alignment.centerLeft,
|
widget.sentBySelf ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
child: Column(
|
child: Stack(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: widget.sentBySelf
|
|
||||||
? CrossAxisAlignment.end
|
|
||||||
: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
Positioned(
|
||||||
onLongPressStart: widget.onLongPressed,
|
bottom: 10,
|
||||||
child: widget.bubble,
|
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
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onLongPressStart: widget.onLongPressed,
|
||||||
|
child: widget.bubble,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_buildReactions(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.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
|
/// Calculate the transformed size of a media message based on its stored
|
||||||
/// dimensions.
|
/// dimensions.
|
||||||
Size getMediaSize(Message message, double maxWidth) {
|
Size getMediaSize(Message message, double maxWidth) {
|
||||||
final mediaWidth = message.mediaWidth?.toDouble();
|
final mediaWidth = message.fileMetadata?.width?.toDouble();
|
||||||
final mediaHeight = message.mediaHeight?.toDouble();
|
final mediaHeight = message.fileMetadata?.height?.toDouble();
|
||||||
|
|
||||||
var width = maxWidth;
|
var width = maxWidth;
|
||||||
var height = maxWidth;
|
var height = maxWidth;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/message/audio.dart';
|
import 'package:moxxyv2/ui/widgets/chat/message/audio.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
|
||||||
@@ -37,7 +38,7 @@ MessageType getMessageType(Message message) {
|
|||||||
return MessageType.sticker;
|
return MessageType.sticker;
|
||||||
}
|
}
|
||||||
|
|
||||||
final mime = message.mediaType;
|
final mime = message.fileMetadata!.mimeType;
|
||||||
if (mime == null) return MessageType.file;
|
if (mime == null) return MessageType.file;
|
||||||
|
|
||||||
if (mime.startsWith('image/')) {
|
if (mime.startsWith('image/')) {
|
||||||
@@ -60,6 +61,8 @@ Widget buildMessageWidget(
|
|||||||
double maxWidth,
|
double maxWidth,
|
||||||
BorderRadius radius,
|
BorderRadius radius,
|
||||||
bool sent,
|
bool sent,
|
||||||
|
double topLeftRadius,
|
||||||
|
double topRightRadius,
|
||||||
) {
|
) {
|
||||||
// Retracted messages are always rendered as a text message
|
// Retracted messages are always rendered as a text message
|
||||||
if (message.isRetracted) {
|
if (message.isRetracted) {
|
||||||
@@ -67,7 +70,12 @@ Widget buildMessageWidget(
|
|||||||
message,
|
message,
|
||||||
sent,
|
sent,
|
||||||
topWidget: message.quotes != null
|
topWidget: message.quotes != null
|
||||||
? buildQuoteMessageWidget(message.quotes!, sent)
|
? buildQuoteMessageWidget(
|
||||||
|
message.quotes!,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,7 +87,12 @@ Widget buildMessageWidget(
|
|||||||
message,
|
message,
|
||||||
sent,
|
sent,
|
||||||
topWidget: message.quotes != null
|
topWidget: message.quotes != null
|
||||||
? buildQuoteMessageWidget(message.quotes!, sent)
|
? buildQuoteMessageWidget(
|
||||||
|
message.quotes!,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,7 +101,19 @@ Widget buildMessageWidget(
|
|||||||
case MessageType.video:
|
case MessageType.video:
|
||||||
return VideoChatWidget(message, radius, maxWidth, sent);
|
return VideoChatWidget(message, radius, maxWidth, sent);
|
||||||
case MessageType.sticker:
|
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:
|
case MessageType.audio:
|
||||||
return AudioChatWidget(message, radius, maxWidth, sent);
|
return AudioChatWidget(message, radius, maxWidth, sent);
|
||||||
case MessageType.file:
|
case MessageType.file:
|
||||||
@@ -99,48 +124,86 @@ Widget buildMessageWidget(
|
|||||||
/// Build a widget that represents a quoted message within another bubble.
|
/// Build a widget that represents a quoted message within another bubble.
|
||||||
Widget buildQuoteMessageWidget(
|
Widget buildQuoteMessageWidget(
|
||||||
Message message,
|
Message message,
|
||||||
bool sent, {
|
bool sent,
|
||||||
|
double topLeftRadius,
|
||||||
|
double topRightRadius, {
|
||||||
void Function()? resetQuote,
|
void Function()? resetQuote,
|
||||||
}) {
|
}) {
|
||||||
switch (getMessageType(message)) {
|
switch (getMessageType(message)) {
|
||||||
case MessageType.sticker:
|
case MessageType.sticker:
|
||||||
return QuotedStickerWidget(message, sent, resetQuote: resetQuote);
|
return QuotedStickerWidget(
|
||||||
|
message,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
resetQuote: resetQuote,
|
||||||
|
);
|
||||||
case MessageType.text:
|
case MessageType.text:
|
||||||
return QuotedTextWidget(message, sent, resetQuote: resetQuote);
|
return QuotedTextWidget(
|
||||||
|
message,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
resetQuote: resetQuote,
|
||||||
|
);
|
||||||
case MessageType.image:
|
case MessageType.image:
|
||||||
return QuotedImageWidget(message, sent, resetQuote: resetQuote);
|
return QuotedImageWidget(
|
||||||
|
message,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
resetQuote: resetQuote,
|
||||||
|
);
|
||||||
case MessageType.video:
|
case MessageType.video:
|
||||||
return QuotedVideoWidget(message, sent, resetQuote: resetQuote);
|
return QuotedVideoWidget(
|
||||||
|
message,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
resetQuote: resetQuote,
|
||||||
|
);
|
||||||
case MessageType.audio:
|
case MessageType.audio:
|
||||||
return QuotedAudioWidget(message, sent, resetQuote: resetQuote);
|
return QuotedAudioWidget(
|
||||||
|
message,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
resetQuote: resetQuote,
|
||||||
|
);
|
||||||
case MessageType.file:
|
case MessageType.file:
|
||||||
return QuotedFileWidget(message, sent, resetQuote: resetQuote);
|
return QuotedFileWidget(
|
||||||
|
message,
|
||||||
|
sent,
|
||||||
|
topLeftRadius,
|
||||||
|
topRightRadius,
|
||||||
|
resetQuote: resetQuote,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildSharedMediaWidget(SharedMedium medium, String conversationJid) {
|
Widget buildSharedMediaWidget(FileMetadata metadata, String conversationJid) {
|
||||||
if (medium.mime!.startsWith('image/')) {
|
if (metadata.mimeType!.startsWith('image/')) {
|
||||||
return SharedImageWidget(
|
return SharedImageWidget(
|
||||||
medium.path,
|
metadata.path!,
|
||||||
onTap: () => openFile(medium.path),
|
onTap: () => openFile(metadata.path!),
|
||||||
);
|
);
|
||||||
} else if (medium.mime!.startsWith('video/')) {
|
} else if (metadata.mimeType!.startsWith('video/')) {
|
||||||
return SharedVideoWidget(
|
return SharedVideoWidget(
|
||||||
medium.path,
|
metadata.path!,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
medium.mime!,
|
metadata.mimeType!,
|
||||||
onTap: () => openFile(medium.path),
|
onTap: () => openFile(metadata.path!),
|
||||||
child: const PlayButton(size: 32),
|
child: const PlayButton(size: 32),
|
||||||
);
|
);
|
||||||
} else if (medium.mime!.startsWith('audio/')) {
|
} else if (metadata.mimeType!.startsWith('audio/')) {
|
||||||
return SharedAudioWidget(
|
return SharedAudioWidget(
|
||||||
medium.path,
|
metadata.path!,
|
||||||
onTap: () => openFile(medium.path),
|
onTap: () => openFile(metadata.path!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SharedFileWidget(
|
return SharedFileWidget(
|
||||||
medium.path,
|
metadata.path!,
|
||||||
onTap: () => openFile(medium.path),
|
onTap: () => openFile(metadata.path!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class AudioChatState extends State<AudioChatWidget> {
|
|||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
_audioFile = Audio.loadFromAbsolutePath(
|
_audioFile = Audio.loadFromAbsolutePath(
|
||||||
widget.message.mediaUrl!,
|
widget.message.fileMetadata!.path!,
|
||||||
onDuration: (double seconds) {
|
onDuration: (double seconds) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_duration = seconds;
|
_duration = seconds;
|
||||||
@@ -251,11 +251,11 @@ class AudioChatState extends State<AudioChatWidget> {
|
|||||||
Widget _buildDownloadable() {
|
Widget _buildDownloadable() {
|
||||||
return FileChatBaseWidget(
|
return FileChatBaseWidget(
|
||||||
widget.message,
|
widget.message,
|
||||||
widget.message.filename!,
|
widget.message.fileMetadata!.filename,
|
||||||
widget.radius,
|
widget.radius,
|
||||||
widget.maxWidth,
|
widget.maxWidth,
|
||||||
widget.sent,
|
widget.sent,
|
||||||
mimeType: widget.message.mediaType,
|
mimeType: widget.message.fileMetadata!.mimeType,
|
||||||
downloadButton: DownloadButton(
|
downloadButton: DownloadButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
@@ -276,8 +276,8 @@ class AudioChatState extends State<AudioChatWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
// TODO(PapaTutuWawa): Maybe use an async builder
|
||||||
if (widget.message.mediaUrl != null &&
|
if (widget.message.fileMetadata!.path != null &&
|
||||||
File(widget.message.mediaUrl!).existsSync()) {
|
File(widget.message.fileMetadata!.path!).existsSync()) {
|
||||||
return _buildAudio();
|
return _buildAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,11 +123,11 @@ class FileChatWidget extends StatelessWidget {
|
|||||||
Widget _buildNonDownloaded() {
|
Widget _buildNonDownloaded() {
|
||||||
return FileChatBaseWidget(
|
return FileChatBaseWidget(
|
||||||
message,
|
message,
|
||||||
message.filename!,
|
message.fileMetadata!.filename,
|
||||||
radius,
|
radius,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
sent,
|
sent,
|
||||||
mimeType: message.mediaType,
|
mimeType: message.fileMetadata!.mimeType,
|
||||||
downloadButton: DownloadButton(
|
downloadButton: DownloadButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
@@ -142,11 +142,11 @@ class FileChatWidget extends StatelessWidget {
|
|||||||
Widget _buildDownloading() {
|
Widget _buildDownloading() {
|
||||||
return FileChatBaseWidget(
|
return FileChatBaseWidget(
|
||||||
message,
|
message,
|
||||||
message.filename!,
|
message.fileMetadata!.filename,
|
||||||
radius,
|
radius,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
sent,
|
sent,
|
||||||
mimeType: message.mediaType,
|
mimeType: message.fileMetadata!.filename,
|
||||||
downloadButton: ProgressWidget(id: message.id),
|
downloadButton: ProgressWidget(id: message.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,20 +154,20 @@ class FileChatWidget extends StatelessWidget {
|
|||||||
Widget _buildInner() {
|
Widget _buildInner() {
|
||||||
return FileChatBaseWidget(
|
return FileChatBaseWidget(
|
||||||
message,
|
message,
|
||||||
message.filename!,
|
message.fileMetadata!.filename,
|
||||||
radius,
|
radius,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
sent,
|
sent,
|
||||||
mimeType: message.mediaType,
|
mimeType: message.fileMetadata!.mimeType,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
openFile(message.mediaUrl!);
|
openFile(message.fileMetadata!.path!);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!message.isDownloading && message.mediaUrl != null) {
|
if (!message.isDownloading && message.fileMetadata!.path != null) {
|
||||||
return _buildInner();
|
return _buildInner();
|
||||||
}
|
}
|
||||||
if (message.isFileUploadNotification || message.isDownloading) {
|
if (message.isFileUploadNotification || message.isDownloading) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildUploading() {
|
Widget _buildUploading() {
|
||||||
return MediaBaseChatWidget(
|
return MediaBaseChatWidget(
|
||||||
Image.file(File(message.mediaUrl!)),
|
Image.file(File(message.fileMetadata!.path!)),
|
||||||
MessageBubbleBottom(message, sent),
|
MessageBubbleBottom(message, sent),
|
||||||
radius,
|
radius,
|
||||||
extra: ProgressWidget(id: message.id),
|
extra: ProgressWidget(id: message.id),
|
||||||
@@ -35,7 +35,7 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDownloading() {
|
Widget _buildDownloading() {
|
||||||
if (message.thumbnailData != null) {
|
if (message.fileMetadata!.thumbnailData != null) {
|
||||||
final size = getMediaSize(message, maxWidth);
|
final size = getMediaSize(message, maxWidth);
|
||||||
|
|
||||||
return MediaBaseChatWidget(
|
return MediaBaseChatWidget(
|
||||||
@@ -43,7 +43,7 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
child: BlurHash(
|
child: BlurHash(
|
||||||
hash: message.thumbnailData!,
|
hash: message.fileMetadata!.thumbnailData!,
|
||||||
decodingWidth: size.width.toInt(),
|
decodingWidth: size.width.toInt(),
|
||||||
decodingHeight: size.height.toInt(),
|
decodingHeight: size.height.toInt(),
|
||||||
),
|
),
|
||||||
@@ -55,11 +55,11 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
} else {
|
} else {
|
||||||
return FileChatBaseWidget(
|
return FileChatBaseWidget(
|
||||||
message,
|
message,
|
||||||
message.filename!,
|
message.fileMetadata!.filename,
|
||||||
radius,
|
radius,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
sent,
|
sent,
|
||||||
mimeType: message.mediaType,
|
mimeType: message.fileMetadata!.mimeType,
|
||||||
downloadButton: ProgressWidget(id: message.id),
|
downloadButton: ProgressWidget(id: message.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,21 +70,23 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
final size = getMediaSize(message, maxWidth);
|
final size = getMediaSize(message, maxWidth);
|
||||||
|
|
||||||
Widget image;
|
Widget image;
|
||||||
if (message.mediaWidth != null && message.mediaHeight != null) {
|
if (message.fileMetadata!.width != null &&
|
||||||
|
message.fileMetadata!.height != null) {
|
||||||
image = SizedBox(
|
image = SizedBox(
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
child: Image.file(
|
child: Image.file(
|
||||||
File(message.mediaUrl!),
|
File(message.fileMetadata!.path!),
|
||||||
cacheWidth: size.width.toInt(),
|
cacheWidth: size.width.toInt(),
|
||||||
cacheHeight: size.height.toInt(),
|
cacheHeight: size.height.toInt(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// TODO(Unknown): Somehow have sensible defaults here
|
||||||
image = Image.file(
|
image = Image.file(
|
||||||
File(message.mediaUrl!),
|
File(message.fileMetadata!.path!),
|
||||||
cacheWidth: size.width.toInt(),
|
// cacheWidth: size.width.toInt(),
|
||||||
cacheHeight: size.height.toInt(),
|
// cacheHeight: size.height.toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +97,12 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
sent,
|
sent,
|
||||||
),
|
),
|
||||||
radius,
|
radius,
|
||||||
onTap: () => openFile(message.mediaUrl!),
|
onTap: () => openFile(message.fileMetadata!.path!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDownloadable() {
|
Widget _buildDownloadable() {
|
||||||
if (message.thumbnailData != null) {
|
if (message.fileMetadata!.thumbnailData != null) {
|
||||||
final size = getMediaSize(message, maxWidth);
|
final size = getMediaSize(message, maxWidth);
|
||||||
|
|
||||||
return MediaBaseChatWidget(
|
return MediaBaseChatWidget(
|
||||||
@@ -108,7 +110,7 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
child: BlurHash(
|
child: BlurHash(
|
||||||
hash: message.thumbnailData!,
|
hash: message.fileMetadata!.thumbnailData!,
|
||||||
decodingWidth: size.width.toInt(),
|
decodingWidth: size.width.toInt(),
|
||||||
decodingHeight: size.height.toInt(),
|
decodingHeight: size.height.toInt(),
|
||||||
),
|
),
|
||||||
@@ -122,11 +124,11 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
} else {
|
} else {
|
||||||
return FileChatBaseWidget(
|
return FileChatBaseWidget(
|
||||||
message,
|
message,
|
||||||
message.filename!,
|
message.fileMetadata!.filename,
|
||||||
radius,
|
radius,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
sent,
|
sent,
|
||||||
mimeType: message.mediaType,
|
mimeType: message.fileMetadata!.mimeType,
|
||||||
downloadButton: DownloadButton(
|
downloadButton: DownloadButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
@@ -149,7 +151,8 @@ class ImageChatWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO(PapaTutuWawa): Maybe use an async builder
|
// 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();
|
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