Compare commits
18 Commits
73913c4ae6
...
8f1d17636e
Author | SHA1 | Date | |
---|---|---|---|
8f1d17636e | |||
fb1c202586 | |||
d7a4ce022e | |||
64c3796429 | |||
80a517beaa | |||
cec31550f8 | |||
bee760adf5 | |||
155d5747f8 | |||
fd531a360e | |||
c3884a460d | |||
5f5c30673d | |||
f423cd5611 | |||
7e059e13ef | |||
d965fbd57e | |||
55854ec586 | |||
8886c8e695 | |||
d58f5f9a01 | |||
e060b0f549 |
@ -48,6 +48,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
@ -223,7 +223,10 @@
|
||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||
"newChatsSection": "New Conversations",
|
||||
"newChatsMuteByDefault": "Mute new chats by default",
|
||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental"
|
||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
||||
"behaviourSection": "Behaviour",
|
||||
"contactsIntegration": "Contacts integration",
|
||||
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debugging options",
|
||||
|
@ -223,7 +223,10 @@
|
||||
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
||||
"newChatsSection": "Neue Chats",
|
||||
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell"
|
||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
|
||||
"behaviourSection": "Verhalten",
|
||||
"contactsIntegration": "Kontaktintegration",
|
||||
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debuggingoptionen",
|
||||
|
@ -27,7 +27,6 @@ String _cleanBase64String(String original) {
|
||||
}
|
||||
|
||||
class AvatarService {
|
||||
|
||||
AvatarService() : _log = Logger('AvatarService');
|
||||
final Logger _log;
|
||||
|
||||
|
300
lib/service/contacts.dart
Normal file
300
lib/service/contacts.dart
Normal file
@ -0,0 +1,300 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class ContactWrapper {
|
||||
const ContactWrapper(this.id, this.jid, this.displayName, this.thumbnail);
|
||||
final String id;
|
||||
final String jid;
|
||||
final String displayName;
|
||||
final Uint8List? thumbnail;
|
||||
}
|
||||
|
||||
class ContactsService {
|
||||
ContactsService() {
|
||||
// NOTE: Apparently, this means that if false, contacts that are in 0 groups
|
||||
// are not returned.
|
||||
FlutterContacts.config.includeNonVisibleOnAndroid = true;
|
||||
}
|
||||
final Logger _log = Logger('ContactsService');
|
||||
|
||||
/// JID -> Id
|
||||
Map<String, String>? _contactIds;
|
||||
/// Contact ID -> Display name from the contact or null if we cached that there is
|
||||
/// none
|
||||
final Map<String, String?> _contactDisplayNames = {};
|
||||
|
||||
Future<void> init() async {
|
||||
if (await _canUseContactIntegration()) {
|
||||
enableDatabaseListener();
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable listening to contact database events
|
||||
void enableDatabaseListener() {
|
||||
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
||||
}
|
||||
|
||||
/// Disable listening to contact database events
|
||||
void disableDatabaseListener() {
|
||||
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
|
||||
}
|
||||
|
||||
Future<void> _onContactsDatabaseUpdate() async {
|
||||
_log.finest('Got contacts database update');
|
||||
await scanContacts();
|
||||
}
|
||||
|
||||
/// Queries the contact list for contacts that include a XMPP URI.
|
||||
Future<List<ContactWrapper>> _fetchContactsWithJabber() async {
|
||||
final contacts = await FlutterContacts.getContacts(
|
||||
withProperties: true,
|
||||
withThumbnail: true,
|
||||
);
|
||||
_log.finest('Got ${contacts.length} contacts');
|
||||
|
||||
final jabberContacts = List<ContactWrapper>.empty(growable: true);
|
||||
for (final c in contacts) {
|
||||
final index = c.socialMedias
|
||||
.indexWhere((s) => s.label == SocialMediaLabel.jabber);
|
||||
if (index == -1) continue;
|
||||
|
||||
jabberContacts.add(
|
||||
ContactWrapper(
|
||||
c.id,
|
||||
c.socialMedias[index].userName,
|
||||
c.displayName,
|
||||
c.thumbnail,
|
||||
),
|
||||
);
|
||||
}
|
||||
_log.finest('${jabberContacts.length} contacts have an XMPP address');
|
||||
|
||||
return jabberContacts;
|
||||
}
|
||||
|
||||
/// Checks whether the contact integration is enabled by the user in the preferences.
|
||||
/// Returns true if that is the case. If not, returns false.
|
||||
Future<bool> isContactIntegrationEnabled() async {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
return prefs.enableContactIntegration;
|
||||
}
|
||||
|
||||
/// Checks if we a) have the permission to access the contact list and b) if the
|
||||
/// user wants to use this integration.
|
||||
/// Returns true if we can proceed with accessing the contact list. False, if not.
|
||||
Future<bool> _canUseContactIntegration() async {
|
||||
if (!(await isContactIntegrationEnabled())) {
|
||||
_log.finest('_canUseContactIntegration: Returning false since enableContactIntegration is false');
|
||||
return false;
|
||||
}
|
||||
|
||||
final permission = await Permission.contacts.status;
|
||||
if (permission == PermissionStatus.denied) {
|
||||
_log.finest("_canUseContactIntegration: Returning false since we don't have the contacts permission");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Queries the database for the mapping of JID -> Contact ID. The result is
|
||||
/// cached after the first call.
|
||||
Future<Map<String, String>> _getContactIds() async {
|
||||
if (_contactIds != null) return _contactIds!;
|
||||
|
||||
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds();
|
||||
return _contactIds!;
|
||||
}
|
||||
|
||||
/// Queries the contact list, if enabled and allowed, and returns the contact's
|
||||
/// display name.
|
||||
///
|
||||
/// [id] is the id of the contact. A null value indicates that there is no
|
||||
/// contact and null will be returned immediately.
|
||||
Future<String?> getContactDisplayName(String? id) async {
|
||||
if (id == null ||
|
||||
!(await _canUseContactIntegration())) return null;
|
||||
if (_contactDisplayNames.containsKey(id)) return _contactDisplayNames[id];
|
||||
|
||||
final result = await FlutterContacts.getContact(
|
||||
id,
|
||||
withThumbnail: false,
|
||||
);
|
||||
_contactDisplayNames[id] = result?.displayName;
|
||||
return result?.displayName;
|
||||
}
|
||||
|
||||
/// Returns the contact Id for the JID [jid]. If either the contact integration is
|
||||
/// disabled, not possible (due to missing permissions) or there is no contact with
|
||||
/// [jid] as their Jabber attribute, returns null.
|
||||
Future<String?> getContactIdForJid(String jid) async {
|
||||
if (!(await _canUseContactIntegration())) return null;
|
||||
|
||||
return (await _getContactIds())[jid];
|
||||
}
|
||||
|
||||
/// Returns the path to the avatar file for the contact with JID [jid] as their
|
||||
/// Jabber attribute. If either the contact integration is disabled, not possible
|
||||
/// (due to missing permissions) or there is no contact with [jid] as their Jabber
|
||||
/// attribute, returns null.
|
||||
Future<String?> getProfilePicturePathForJid(String jid) async {
|
||||
final id = await getContactIdForJid(jid);
|
||||
if (id == null) return null;
|
||||
|
||||
final avatarPath = await getContactProfilePicturePath(id);
|
||||
return File(avatarPath).existsSync() ?
|
||||
avatarPath :
|
||||
null;
|
||||
}
|
||||
|
||||
Future<void> scanContacts() async {
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final contacts = await _fetchContactsWithJabber();
|
||||
// JID -> Id
|
||||
final knownContactIds = await _getContactIds();
|
||||
// Id -> JID
|
||||
final knownContactIdsReverse = knownContactIds
|
||||
.map((key, value) => MapEntry(value, key));
|
||||
final modifiedRosterItems = List<RosterItem>.empty(growable: true);
|
||||
final addedRosterItems = List<RosterItem>.empty(growable: true);
|
||||
final removedRosterItems = List<String>.empty(growable: true);
|
||||
|
||||
for (final id in List<String>.from(knownContactIds.values)) {
|
||||
final index = contacts.indexWhere((c) => c.id == id);
|
||||
if (index != -1) continue;
|
||||
|
||||
final jid = knownContactIdsReverse[id]!;
|
||||
await db.removeContactId(id);
|
||||
_contactIds!.remove(knownContactIdsReverse[id]);
|
||||
|
||||
// Remove the avatar file, if it existed
|
||||
final avatarPath = await getContactProfilePicturePath(id);
|
||||
final avatarFile = File(avatarPath);
|
||||
if (avatarFile.existsSync()) {
|
||||
unawaited(avatarFile.delete());
|
||||
}
|
||||
|
||||
// Remove the contact attributes from the conversation, if it existed
|
||||
final c = await cs.getConversationByJid(jid);
|
||||
if (c != null) {
|
||||
final newConv = await cs.updateConversation(
|
||||
c.id,
|
||||
contactId: null,
|
||||
contactAvatarPath: null,
|
||||
contactDisplayName: null,
|
||||
);
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConv,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the contact attributes from the roster item, if it existed
|
||||
final r = await rs.getRosterItemByJid(jid);
|
||||
if (r != null) {
|
||||
if (r.pseudoRosterItem) {
|
||||
_log.finest('Removing pseudo roster item $jid');
|
||||
await rs.removeRosterItem(r.id);
|
||||
removedRosterItems.add(jid);
|
||||
} else {
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
r.id,
|
||||
contactId: null,
|
||||
contactAvatarPath: null,
|
||||
contactDisplayName: null,
|
||||
);
|
||||
modifiedRosterItems.add(newRosterItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final contact in contacts) {
|
||||
// Add the ID to the cache and the database if it does not already exist
|
||||
if (!knownContactIds.containsKey(contact.jid)) {
|
||||
await db.addContactId(contact.id, contact.jid);
|
||||
_contactIds![contact.jid] = contact.id;
|
||||
}
|
||||
|
||||
// Store the avatar image
|
||||
// NOTE: We do not check if the file already exists since this function may also
|
||||
// be triggered by the contact database listener. That listener fires when
|
||||
// a change happened, without telling us exactly what happened. So, we
|
||||
// just overwrite it.
|
||||
final contactAvatarPath = await getContactProfilePicturePath(contact.id);
|
||||
if (contact.thumbnail != null) {
|
||||
final file = File(contactAvatarPath);
|
||||
await file.writeAsBytes(contact.thumbnail!);
|
||||
}
|
||||
|
||||
// Update a possibly existing conversation
|
||||
final c = await cs.getConversationByJid(contact.jid);
|
||||
if (c != null) {
|
||||
final newConv = await cs.updateConversation(
|
||||
c.id,
|
||||
contactId: contact.id,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contact.displayName,
|
||||
);
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConv,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update a possibly existing roster item
|
||||
final r = await rs.getRosterItemByJid(contact.jid);
|
||||
if (r != null) {
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
r.id,
|
||||
contactId: contact.id,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contact.displayName,
|
||||
);
|
||||
modifiedRosterItems.add(newRosterItem);
|
||||
} else {
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
contact.jid,
|
||||
contact.jid.split('@').first,
|
||||
'none',
|
||||
'none',
|
||||
true,
|
||||
contact.id,
|
||||
contactAvatarPath,
|
||||
contact.displayName,
|
||||
);
|
||||
addedRosterItems.add(newRosterItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (addedRosterItems.isNotEmpty ||
|
||||
modifiedRosterItems.isNotEmpty ||
|
||||
removedRosterItems.isNotEmpty) {
|
||||
sendEvent(
|
||||
RosterDiffEvent(
|
||||
added: addedRosterItems,
|
||||
modified: modifiedRosterItems,
|
||||
removed: removedRosterItems,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/shared/cache.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
@ -64,6 +65,9 @@ class ConversationService {
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}) async {
|
||||
final conversation = (await _getConversationById(id))!;
|
||||
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
||||
@ -76,6 +80,9 @@ class ConversationService {
|
||||
chatState: conversation.chatState,
|
||||
muted: muted,
|
||||
encrypted: encrypted,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
// Copy over the old lastMessage if a new one was not set
|
||||
@ -98,6 +105,9 @@ class ConversationService {
|
||||
bool open,
|
||||
bool muted,
|
||||
bool encrypted,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
) async {
|
||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||
title,
|
||||
@ -109,6 +119,9 @@ class ConversationService {
|
||||
open,
|
||||
muted,
|
||||
encrypted,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
);
|
||||
|
||||
_conversationCache.cache(newConversation.id, newConversation);
|
||||
|
@ -11,6 +11,7 @@ const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
||||
const xmppStateTable = 'XmppState';
|
||||
const contactsTable = 'Contacts';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
@ -73,10 +73,24 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id)
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)'''
|
||||
);
|
||||
|
||||
// Shared media
|
||||
await db.execute(
|
||||
'''
|
||||
@ -102,7 +116,13 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
avatarUrl TEXT NOT NULL,
|
||||
avatarHash TEXT NOT NULL,
|
||||
subscription TEXT NOT NULL,
|
||||
ask TEXT NOT NULL
|
||||
ask TEXT NOT NULL,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
pseudoRosterItem INTEGER NOT NULL,
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
@ -347,4 +367,12 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
'default',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'enableContactIntegration',
|
||||
typeBool,
|
||||
'false',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
|
@ -8,6 +8,9 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/creation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_avatar.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_pseudo.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
|
||||
@ -67,7 +70,7 @@ class DatabaseService {
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 13,
|
||||
version: 16,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
@ -128,6 +131,18 @@ class DatabaseService {
|
||||
_log.finest('Running migration for database version 13');
|
||||
await upgradeFromV12ToV13(db);
|
||||
}
|
||||
if (oldVersion < 14) {
|
||||
_log.finest('Running migration for database version 14');
|
||||
await upgradeFromV13ToV14(db);
|
||||
}
|
||||
if (oldVersion < 15) {
|
||||
_log.finest('Running migration for database version 15');
|
||||
await upgradeFromV14ToV15(db);
|
||||
}
|
||||
if (oldVersion < 16) {
|
||||
_log.finest('Running migration for database version 16');
|
||||
await upgradeFromV15ToV16(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -161,7 +176,7 @@ class DatabaseService {
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem != null,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
sharedMediaRaw,
|
||||
lastMessage,
|
||||
@ -209,6 +224,9 @@ class DatabaseService {
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}) async {
|
||||
final cd = (await _db.query(
|
||||
'Conversations',
|
||||
@ -245,6 +263,15 @@ class DatabaseService {
|
||||
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?;
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'Conversations',
|
||||
@ -275,6 +302,9 @@ class DatabaseService {
|
||||
bool open,
|
||||
bool muted,
|
||||
bool encrypted,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
) async {
|
||||
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
final conversation = Conversation(
|
||||
@ -287,11 +317,14 @@ class DatabaseService {
|
||||
<SharedMedium>[],
|
||||
-1,
|
||||
open,
|
||||
rosterItem != null,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
muted,
|
||||
encrypted,
|
||||
ChatState.gone,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
return conversation.copyWith(
|
||||
@ -606,6 +639,10 @@ class DatabaseService {
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups = const [],
|
||||
}
|
||||
@ -619,7 +656,11 @@ class DatabaseService {
|
||||
title,
|
||||
subscription,
|
||||
ask,
|
||||
pseudoRosterItem,
|
||||
<String>[],
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
return i.copyWith(
|
||||
@ -635,11 +676,15 @@ class DatabaseService {
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem = notSpecified,
|
||||
List<String>? groups,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}
|
||||
) async {
|
||||
final id_ = (await _db.query(
|
||||
'RosterItems',
|
||||
rosterTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
@ -666,9 +711,21 @@ class DatabaseService {
|
||||
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);
|
||||
}
|
||||
|
||||
await _db.update(
|
||||
'RosterItems',
|
||||
rosterTable,
|
||||
i,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
@ -1042,4 +1099,33 @@ class DatabaseService {
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> getContactIds() async {
|
||||
return Map<String, String>.fromEntries(
|
||||
(await _db.query(contactsTable))
|
||||
.map((item) => MapEntry(
|
||||
item['jid']! as String,
|
||||
item['id']! as String,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addContactId(String id, String jid) async {
|
||||
await _db.insert(
|
||||
contactsTable,
|
||||
<String, String>{
|
||||
'id': id,
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeContactId(String id) async {
|
||||
await _db.delete(
|
||||
contactsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,68 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV13ToV14(Database db) async {
|
||||
// Create the new table
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)'''
|
||||
);
|
||||
|
||||
// Migrate the conversations
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${conversationsTable}_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
avatarUrl TEXT NOT NULL,
|
||||
lastChangeTimestamp INTEGER NOT NULL,
|
||||
unreadCounter INTEGER NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
contactId TEXT,
|
||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute('INSERT INTO ${conversationsTable}_new SELECT *, NULL from $conversationsTable');
|
||||
await db.execute('DROP TABLE $conversationsTable;');
|
||||
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;');
|
||||
|
||||
// Migrate the roster items
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${rosterTable}_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
avatarUrl TEXT NOT NULL,
|
||||
avatarHash TEXT NOT NULL,
|
||||
subscription TEXT NOT NULL,
|
||||
ask TEXT NOT NULL,
|
||||
contactId TEXT,
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute('INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable');
|
||||
await db.execute('DROP TABLE $rosterTable;');
|
||||
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable;');
|
||||
|
||||
// Introduce the new preference key
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'enableContactIntegration',
|
||||
typeBool,
|
||||
'false',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV14ToV15(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable ADD COLUMN contactAvatarPath TEXT DEFAULT NULL;',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE $rosterTable ADD COLUMN contactAvatarPath TEXT DEFAULT NULL;',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable ADD COLUMN contactDisplayName TEXT DEFAULT NULL;',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE $rosterTable ADD COLUMN contactDisplayName TEXT DEFAULT NULL;',
|
||||
);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV15ToV16(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $rosterTable ADD COLUMN pseudoRosterItem INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
|
||||
);
|
||||
}
|
@ -7,6 +7,7 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/avatars.dart';
|
||||
import 'package:moxxyv2/service/blocking.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
@ -209,7 +210,9 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final contactId = await css.getContactIdForJid(command.jid);
|
||||
final conversation = await cs.addConversationFromData(
|
||||
command.title,
|
||||
null,
|
||||
@ -220,6 +223,9 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
||||
true,
|
||||
preferences.defaultMuteState,
|
||||
preferences.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(command.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
@ -308,7 +314,9 @@ Future<void> performSetCSIState(SetCSIStateCommand command, { dynamic extra }) a
|
||||
}
|
||||
|
||||
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
|
||||
await GetIt.I.get<PreferencesService>().modifyPreferences((_) => command.preferences);
|
||||
final ps = GetIt.I.get<PreferencesService>();
|
||||
final oldPrefs = await ps.getPreferences();
|
||||
await ps.modifyPreferences((_) => command.preferences);
|
||||
|
||||
// Set the logging mode
|
||||
if (!kDebugMode) {
|
||||
@ -316,6 +324,21 @@ Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extr
|
||||
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
|
||||
}
|
||||
|
||||
// Scan all contacts if the setting is enabled or disable the database callback
|
||||
// if it is disabled.
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
if (command.preferences.enableContactIntegration) {
|
||||
if (!oldPrefs.enableContactIntegration) {
|
||||
css.enableDatabaseListener();
|
||||
}
|
||||
|
||||
unawaited(css.scanContacts());
|
||||
} else {
|
||||
if (oldPrefs.enableContactIntegration) {
|
||||
css.disableDatabaseListener();
|
||||
}
|
||||
}
|
||||
|
||||
// Set the locale
|
||||
final locale = command.preferences.languageLocaleCode == 'default' ?
|
||||
GetIt.I.get<LanguageService>().defaultLocale :
|
||||
@ -350,6 +373,8 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
||||
id: id,
|
||||
);
|
||||
} else {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final c = await cs.addConversationFromData(
|
||||
jid.split('@')[0],
|
||||
@ -361,6 +386,9 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
sendEvent(
|
||||
AddContactResultEvent(conversation: c, added: true),
|
||||
|
@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/events.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
@ -85,21 +86,31 @@ class NotificationsService {
|
||||
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
||||
/// then Android's BigPicture will be used.
|
||||
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async {
|
||||
// TODO(Unknown): Keep track of notifications to create a summary notification
|
||||
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
||||
final body = m.isMedia ?
|
||||
mimeTypeToEmoji(m.mediaType) :
|
||||
m.body;
|
||||
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
|
||||
final title = contactIntegrationEnabled ?
|
||||
c.contactDisplayName ?? c.title :
|
||||
c.title;
|
||||
final avatarPath = contactIntegrationEnabled ?
|
||||
c.contactAvatarPath ?? c.avatarUrl :
|
||||
c.avatarUrl;
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
id: m.id,
|
||||
groupKey: c.jid,
|
||||
channelKey: _messageChannelKey,
|
||||
summary: c.title,
|
||||
title: c.title,
|
||||
summary: title,
|
||||
title: title,
|
||||
body: body,
|
||||
largeIcon: c.avatarUrl.isNotEmpty ? 'file://${c.avatarUrl}' : null,
|
||||
largeIcon: avatarPath.isNotEmpty ?
|
||||
'file://$avatarPath' :
|
||||
null,
|
||||
notificationLayout: m.isThumbnailable ?
|
||||
NotificationLayout.BigPicture :
|
||||
NotificationLayout.Messaging,
|
||||
@ -108,8 +119,8 @@ class NotificationsService {
|
||||
payload: <String, String>{
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
'title': c.title,
|
||||
'avatarUrl': c.avatarUrl,
|
||||
'title': title,
|
||||
'avatarUrl': avatarPath,
|
||||
},
|
||||
),
|
||||
actionButtons: [
|
||||
|
@ -6,8 +6,10 @@ import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
@ -25,6 +27,10 @@ typedef AddRosterItemFunction = Future<RosterItem> Function(
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups,
|
||||
}
|
||||
@ -36,6 +42,7 @@ typedef UpdateRosterItemFunction = Future<RosterItem> Function(
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem,
|
||||
List<String>? groups,
|
||||
}
|
||||
);
|
||||
@ -56,6 +63,7 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
GetConversationFunction getConversationByJid,
|
||||
SendEventFunction _sendEvent,
|
||||
) async {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final removed = List<String>.empty(growable: true);
|
||||
final modified = List<RosterItem>.empty(growable: true);
|
||||
final added = List<RosterItem>.empty(growable: true);
|
||||
@ -66,8 +74,21 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
if (litem != null) {
|
||||
if (item.subscription == 'remove') {
|
||||
// We have the item locally but it has been removed
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
|
||||
if (litem.contactId != null) {
|
||||
// We have the contact associated with a contact
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
ask: 'none',
|
||||
subscription: 'none',
|
||||
pseudoRosterItem: true,
|
||||
);
|
||||
modified.add(newItem);
|
||||
} else {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -77,6 +98,7 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
subscription: item.subscription,
|
||||
title: item.name,
|
||||
ask: item.ask,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
@ -98,6 +120,7 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
removed.add(item.jid);
|
||||
} else {
|
||||
// Item has been added and we don't have it locally
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
final newItem = await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
@ -105,6 +128,10 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
item.name ?? item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
@ -120,6 +147,7 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
litem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
modified.add(modifiedItem);
|
||||
@ -136,6 +164,7 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
}
|
||||
} else {
|
||||
// Item is new
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
added.add(await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
@ -143,6 +172,10 @@ Future<RosterDiffEvent> processRosterDiff(
|
||||
item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
),);
|
||||
}
|
||||
@ -194,6 +227,10 @@ class RosterService {
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups = const [],
|
||||
}
|
||||
@ -205,6 +242,10 @@ class RosterService {
|
||||
title,
|
||||
subscription,
|
||||
ask,
|
||||
pseudoRosterItem,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
groups: groups,
|
||||
);
|
||||
|
||||
@ -222,7 +263,11 @@ class RosterService {
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem = notSpecified,
|
||||
List<String>? groups,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}
|
||||
) async {
|
||||
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
||||
@ -232,7 +277,11 @@ class RosterService {
|
||||
title: title,
|
||||
subscription: subscription,
|
||||
ask: ask,
|
||||
pseudoRosterItem: pseudoRosterItem,
|
||||
groups: groups,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
// Update cache
|
||||
@ -298,6 +347,8 @@ class RosterService {
|
||||
/// and, if it was successful, create the database entry. Returns the
|
||||
/// [RosterItem] model object.
|
||||
Future<RosterItem> addToRosterWrapper(String avatarUrl, String avatarHash, String jid, String title) async {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final item = await addRosterItemFromData(
|
||||
avatarUrl,
|
||||
avatarHash,
|
||||
@ -305,6 +356,10 @@ class RosterService {
|
||||
title,
|
||||
'none',
|
||||
'',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title);
|
||||
if (!result) {
|
||||
|
@ -13,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
|
||||
import 'package:moxxyv2/service/blocking.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
@ -153,10 +154,12 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<MessageService>(MessageService());
|
||||
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
||||
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
||||
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||
final xmpp = XmppService();
|
||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||
|
||||
await GetIt.I.get<NotificationsService>().init();
|
||||
await GetIt.I.get<ContactsService>().init();
|
||||
|
||||
if (!kDebugMode) {
|
||||
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
||||
|
@ -13,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
|
||||
import 'package:moxxyv2/service/blocking.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
@ -395,6 +396,7 @@ class XmppService {
|
||||
// Create a new message
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
|
||||
// Path -> Recipient -> Message
|
||||
@ -491,6 +493,7 @@ class XmppService {
|
||||
} else {
|
||||
// Create conversation
|
||||
final rosterItem = await rs.getRosterItemByJid(recipient);
|
||||
final contactId = await css.getContactIdForJid(recipient);
|
||||
var newConversation = await cs.addConversationFromData(
|
||||
// TODO(Unknown): Should we use the JID parser?
|
||||
rosterItem?.title ?? recipient.split('@').first,
|
||||
@ -502,6 +505,9 @@ class XmppService {
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(recipient),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id);
|
||||
@ -686,6 +692,7 @@ class XmppService {
|
||||
|
||||
if (!prefs.showSubscriptionRequests) return;
|
||||
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = await cs.getConversationByJid(event.from.toBare().toString());
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
@ -698,17 +705,21 @@ class XmppService {
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||
} else {
|
||||
// TODO(Unknown): Make it configurable if this should happen
|
||||
final bare = event.from.toBare();
|
||||
final bare = event.from.toBare().toString();
|
||||
final contactId = await css.getContactIdForJid(bare);
|
||||
final conv = await cs.addConversationFromData(
|
||||
bare.toString().split('@')[0],
|
||||
bare.split('@')[0],
|
||||
null,
|
||||
'', // TODO(Unknown): avatarUrl
|
||||
bare.toString(),
|
||||
bare,
|
||||
0,
|
||||
timestamp,
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(bare),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sendEvent(ConversationAddedEvent(conversation: conv));
|
||||
@ -1192,6 +1203,7 @@ class XmppService {
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final ns = GetIt.I.get<NotificationsService>();
|
||||
// The body to be displayed in the conversations list
|
||||
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
|
||||
@ -1231,6 +1243,7 @@ class XmppService {
|
||||
}
|
||||
} else {
|
||||
// The conversation does not exist, so we must create it
|
||||
final contactId = await css.getContactIdForJid(conversationJid);
|
||||
final newConversation = await cs.addConversationFromData(
|
||||
rosterItem?.title ?? conversationJid.split('@')[0],
|
||||
message,
|
||||
@ -1241,6 +1254,9 @@ class XmppService {
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
message.encrypted,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(conversationJid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
// Notify the UI
|
||||
|
@ -389,3 +389,16 @@ Future<String?> getVideoThumbnailPath(String path, String conversationJid, Strin
|
||||
|
||||
return thumbnailPath;
|
||||
}
|
||||
|
||||
Future<String> getContactProfilePicturePath(String id) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final avatarDir = p.join(
|
||||
tempDir.path,
|
||||
'contacts',
|
||||
'avatars',
|
||||
);
|
||||
final dir = Directory(avatarDir);
|
||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||
|
||||
return p.join(avatarDir, id);
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
|
||||
part 'conversation.freezed.dart';
|
||||
part 'conversation.g.dart';
|
||||
@ -59,6 +61,14 @@ class Conversation with _$Conversation {
|
||||
bool encrypted,
|
||||
// The current chat state
|
||||
@ConversationChatStateConverter() ChatState chatState,
|
||||
{
|
||||
// The id of the contact in the device's phonebook if it exists
|
||||
String? contactId,
|
||||
// The path to the contact avatar, if available
|
||||
String? contactAvatarPath,
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}
|
||||
) = _Conversation;
|
||||
|
||||
const Conversation._();
|
||||
@ -101,6 +111,28 @@ class Conversation with _$Conversation {
|
||||
|
||||
/// True, when the chat state of the conversation indicates typing. False, if not.
|
||||
bool get isTyping => chatState == ChatState.composing;
|
||||
|
||||
/// The path to the avatar. This returns, if enabled, first the contact's avatar
|
||||
/// path, then the XMPP avatar's path. If not enabled, just returns the regular
|
||||
/// XMPP avatar's path.
|
||||
String? get avatarPathWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
return contactAvatarPath ?? avatarUrl;
|
||||
}
|
||||
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
/// The title of the chat. This returns, if enabled, first the contact's display
|
||||
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat
|
||||
/// title.
|
||||
String get titleWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
return contactDisplayName ?? title;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts conversations in descending order by their last change timestamp.
|
||||
|
@ -28,6 +28,7 @@ class PreferencesState with _$PreferencesState {
|
||||
// NOTE: A value of 'default' means that the system's configured language should
|
||||
// be used
|
||||
@Default('default') String languageLocaleCode,
|
||||
@Default(false) bool enableContactIntegration,
|
||||
}) = _PreferencesState;
|
||||
|
||||
// JSON serialization
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
|
||||
part 'roster.freezed.dart';
|
||||
part 'roster.g.dart';
|
||||
@ -13,7 +14,18 @@ class RosterItem with _$RosterItem {
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
// Indicates whether the "roster item" really exists on the roster and is not just there
|
||||
// for the contact integration
|
||||
bool pseudoRosterItem,
|
||||
List<String> groups,
|
||||
{
|
||||
// The id of the contact in the device's phonebook, if it exists
|
||||
String? contactId,
|
||||
// The path to the profile picture of the contact, if it exists
|
||||
String? contactAvatarPath,
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}
|
||||
) = _RosterItem;
|
||||
|
||||
const RosterItem._();
|
||||
@ -26,13 +38,20 @@ class RosterItem with _$RosterItem {
|
||||
...json,
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
'groups': <String>[],
|
||||
'pseudoRosterItem': intToBool(json['pseudoRosterItem']! as int),
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
return toJson()
|
||||
final json = toJson()
|
||||
..remove('id')
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
..remove('groups');
|
||||
..remove('groups')
|
||||
..remove('pseudoRosterItem');
|
||||
|
||||
return {
|
||||
...json,
|
||||
'pseudoRosterItem': boolToInt(pseudoRosterItem),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -121,7 +121,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state: s.toString().split('.').last,
|
||||
jid: state.conversation!.jid,
|
||||
),
|
||||
awaitable: false,);
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async {
|
||||
|
@ -89,6 +89,10 @@ class NewConversationBloc extends Bloc<NewConversationEvent, NewConversationStat
|
||||
final roster = List<RosterItem>.from(event.added);
|
||||
|
||||
for (final item in state.roster) {
|
||||
// Handle removed items
|
||||
if (event.removed.contains(item.jid)) continue;
|
||||
|
||||
// Handle modified items
|
||||
final modified = firstWhereOrNull(
|
||||
event.modified,
|
||||
(RosterItem i) => i.id == item.id,
|
||||
|
@ -26,12 +26,26 @@ enum ShareSelectionType {
|
||||
|
||||
/// Create a common ground between Conversations and RosterItems
|
||||
class ShareListItem {
|
||||
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation, this.isEncrypted);
|
||||
const ShareListItem(
|
||||
this.avatarPath,
|
||||
this.jid,
|
||||
this.title,
|
||||
this.isConversation,
|
||||
this.isEncrypted,
|
||||
this.pseudoRosterItem,
|
||||
this.contactId,
|
||||
this.contactAvatarPath,
|
||||
this.contactDisplayName,
|
||||
);
|
||||
final String avatarPath;
|
||||
final String jid;
|
||||
final String title;
|
||||
final bool isConversation;
|
||||
final bool isEncrypted;
|
||||
final bool pseudoRosterItem;
|
||||
final String? contactId;
|
||||
final String? contactAvatarPath;
|
||||
final String? contactDisplayName;
|
||||
}
|
||||
|
||||
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
||||
@ -67,6 +81,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
c.title,
|
||||
true,
|
||||
c.encrypted,
|
||||
false,
|
||||
c.contactId,
|
||||
c.contactAvatarPath,
|
||||
c.contactDisplayName,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@ -83,6 +101,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
rosterItem.title,
|
||||
false,
|
||||
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
||||
rosterItem.pseudoRosterItem,
|
||||
rosterItem.contactId,
|
||||
rosterItem.contactAvatarPath,
|
||||
rosterItem.contactDisplayName,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@ -92,6 +114,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
rosterItem.title,
|
||||
false,
|
||||
items[index].isEncrypted,
|
||||
items[index].pseudoRosterItem,
|
||||
items[index].contactId,
|
||||
items[index].contactAvatarPath,
|
||||
items[index].contactDisplayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
|
||||
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
|
||||
enum ConversationOption {
|
||||
@ -99,10 +100,12 @@ class ConversationTopbar extends StatelessWidget implements PreferredSizeWidget
|
||||
tag: 'conversation_profile_picture',
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
child: AvatarWrapper(
|
||||
radius: 25,
|
||||
avatarUrl: state.conversation!.avatarUrl,
|
||||
altText: state.conversation!.title,
|
||||
child: RebuildOnContactIntegrationChange(
|
||||
builder: () => AvatarWrapper(
|
||||
radius: 25,
|
||||
avatarUrl: state.conversation!.avatarPathWithOptionalContact,
|
||||
altText: state.conversation!.titleWithOptionalContact,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -127,7 +130,11 @@ class ConversationTopbar extends StatelessWidget implements PreferredSizeWidget
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TopbarTitleText(state.conversation!.title),
|
||||
RebuildOnContactIntegrationChange(
|
||||
builder: () => TopbarTitleText(
|
||||
state.conversation!.titleWithOptionalContact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
@ -108,17 +107,6 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _showAvatarFullsize(BuildContext context, String path) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return IgnorePointer(
|
||||
child: Image.file(File(path)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _listWrapper(BuildContext context, ConversationsState state) {
|
||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
||||
|
||||
@ -131,9 +119,7 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
|
||||
maxTextWidth,
|
||||
item,
|
||||
true,
|
||||
avatarOnTap: item.avatarUrl.isNotEmpty ?
|
||||
() => _showAvatarFullsize(context, item.avatarUrl) :
|
||||
null,
|
||||
enableAvatarOnTap: true,
|
||||
key: ValueKey('conversationRow;${item.jid}'),
|
||||
);
|
||||
|
||||
|
@ -72,6 +72,9 @@ class NewConversationPage extends StatelessWidget {
|
||||
final item = state.roster[index - 2];
|
||||
return Dismissible(
|
||||
key: ValueKey('roster;${item.jid}'),
|
||||
direction: item.pseudoRosterItem ?
|
||||
DismissDirection.none :
|
||||
DismissDirection.horizontal,
|
||||
onDismissed: (_) => context.read<NewConversationBloc>().add(
|
||||
NewConversationRosterItemRemovedEvent(item.jid),
|
||||
),
|
||||
@ -124,9 +127,15 @@ class NewConversationPage extends StatelessWidget {
|
||||
false,
|
||||
false,
|
||||
ChatState.gone,
|
||||
contactId: item.contactId,
|
||||
contactAvatarPath: item.contactAvatarPath,
|
||||
contactDisplayName: item.contactDisplayName,
|
||||
),
|
||||
false,
|
||||
showTimestamp: false,
|
||||
titleSuffixIcon: item.pseudoRosterItem ?
|
||||
Icons.smartphone :
|
||||
null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -9,38 +9,44 @@ import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
|
||||
//import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
class ConversationProfileHeader extends StatelessWidget {
|
||||
const ConversationProfileHeader(this.conversation, { super.key });
|
||||
final Conversation conversation;
|
||||
|
||||
Future<void> _showAvatarFullsize(BuildContext context) async {
|
||||
Future<void> _showAvatarFullsize(BuildContext context, String path) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return IgnorePointer(
|
||||
child: Image.file(File(conversation.avatarUrl)),
|
||||
child: Image.file(File(path)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(BuildContext context) {
|
||||
final avatar = AvatarWrapper(
|
||||
radius: 110,
|
||||
avatarUrl: conversation.avatarUrl,
|
||||
altText: conversation.title,
|
||||
return RebuildOnContactIntegrationChange(
|
||||
builder: () {
|
||||
final path = conversation.avatarPathWithOptionalContact;
|
||||
final avatar = AvatarWrapper(
|
||||
radius: 110,
|
||||
avatarUrl: path,
|
||||
altText: conversation.titleWithOptionalContact,
|
||||
);
|
||||
|
||||
if (path != null && path.isNotEmpty) {
|
||||
return InkWell(
|
||||
onTap: () => _showAvatarFullsize(context, path),
|
||||
child: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
},
|
||||
);
|
||||
|
||||
if (conversation.avatarUrl.isNotEmpty) {
|
||||
return InkWell(
|
||||
onTap: () => _showAvatarFullsize(context),
|
||||
child: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -52,15 +58,17 @@ class ConversationProfileHeader extends StatelessWidget {
|
||||
Hero(
|
||||
tag: 'conversation_profile_picture',
|
||||
child: Material(
|
||||
child: _buildAvatar(context),
|
||||
child: _buildAvatar(context,),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
conversation.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
child: RebuildOnContactIntegrationChange(
|
||||
builder: () => Text(
|
||||
conversation.titleWithOptionalContact,
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -12,6 +12,7 @@ import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
class ConversationSettingsPage extends StatelessWidget {
|
||||
@ -102,6 +103,31 @@ class ConversationSettingsPage extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.conversation.behaviourSection),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.conversation.contactsIntegration),
|
||||
description: Text(t.pages.settings.conversation.contactsIntegrationBody),
|
||||
initialValue: state.enableContactIntegration,
|
||||
onToggle: (value) async {
|
||||
// Ensure that we have the permission before changing the value
|
||||
if (value && await Permission.contacts.status == PermissionStatus.denied) {
|
||||
if (!(await Permission.contacts.request().isGranted)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(enableContactIntegration: value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.conversation.newChatsSection),
|
||||
tiles: [
|
||||
|
@ -31,6 +31,18 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
prev.text != next.text ||
|
||||
prev.type != next.type;
|
||||
}
|
||||
|
||||
IconData? _getSuffixIcon(ShareListItem item) {
|
||||
if (item.pseudoRosterItem) {
|
||||
return Icons.smartphone;
|
||||
}
|
||||
|
||||
if (item.isEncrypted) {
|
||||
return Icons.lock;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -85,10 +97,14 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
false,
|
||||
false,
|
||||
ChatState.gone,
|
||||
contactId: item.contactId,
|
||||
contactAvatarPath: item.contactAvatarPath,
|
||||
contactDisplayName: item.contactDisplayName,
|
||||
),
|
||||
false,
|
||||
showLock: item.isEncrypted,
|
||||
titleSuffixIcon: _getSuffixIcon(item),
|
||||
showTimestamp: false,
|
||||
extraWidgetWidth: 48,
|
||||
extra: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) {
|
||||
|
20
lib/ui/widgets/contact_helper.dart
Normal file
20
lib/ui/widgets/contact_helper.dart
Normal file
@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
|
||||
class RebuildOnContactIntegrationChange extends StatelessWidget {
|
||||
const RebuildOnContactIntegrationChange({
|
||||
required this.builder,
|
||||
super.key,
|
||||
});
|
||||
final Widget Function() builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.enableContactIntegration != next.enableContactIntegration,
|
||||
builder: (_, __) => builder(),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@ -12,6 +13,7 @@ import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
|
||||
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
|
||||
|
||||
class ConversationsListRow extends StatefulWidget {
|
||||
const ConversationsListRow(
|
||||
@ -19,18 +21,22 @@ class ConversationsListRow extends StatefulWidget {
|
||||
this.conversation,
|
||||
this.update, {
|
||||
this.showTimestamp = true,
|
||||
this.showLock = false,
|
||||
this.titleSuffixIcon,
|
||||
this.extra,
|
||||
this.avatarOnTap,
|
||||
this.enableAvatarOnTap = false,
|
||||
this.avatarWidget,
|
||||
this.extraWidgetWidth = 0,
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final Conversation conversation;
|
||||
final double maxTextWidth;
|
||||
final double extraWidgetWidth;
|
||||
final bool update; // Should a timer run to update the timestamp
|
||||
final bool showLock;
|
||||
final IconData? titleSuffixIcon;
|
||||
final bool showTimestamp;
|
||||
final void Function()? avatarOnTap;
|
||||
final bool enableAvatarOnTap;
|
||||
final Widget? avatarWidget;
|
||||
final Widget? extra;
|
||||
|
||||
@override
|
||||
@ -84,20 +90,35 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
final avatar = AvatarWrapper(
|
||||
radius: 35,
|
||||
avatarUrl: widget.conversation.avatarUrl,
|
||||
altText: widget.conversation.title,
|
||||
return RebuildOnContactIntegrationChange(
|
||||
builder: () {
|
||||
final avatar = AvatarWrapper(
|
||||
radius: 35,
|
||||
avatarUrl: widget.conversation.avatarPathWithOptionalContact,
|
||||
altText: widget.conversation.titleWithOptionalContact,
|
||||
);
|
||||
|
||||
if (widget.enableAvatarOnTap &&
|
||||
widget.conversation.avatarPathWithOptionalContact != null &&
|
||||
widget.conversation.avatarPathWithOptionalContact!.isNotEmpty) {
|
||||
return InkWell(
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return IgnorePointer(
|
||||
child: Image.file(
|
||||
File(widget.conversation.avatarPathWithOptionalContact!),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
},
|
||||
);
|
||||
|
||||
if (widget.avatarOnTap != null) {
|
||||
return InkWell(
|
||||
onTap: widget.avatarOnTap,
|
||||
child: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
Widget _buildLastMessagePreview() {
|
||||
@ -191,20 +212,21 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final badgeText = widget.conversation.unreadCounter > 99 ?
|
||||
'99+' :
|
||||
widget.conversation.unreadCounter.toString();
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final width = screenWidth - 24 - 70;
|
||||
final width = screenWidth - 24 - 70 - widget.extraWidgetWidth;
|
||||
final textWidth = screenWidth * 0.6;
|
||||
|
||||
final showTimestamp = widget.conversation.lastChangeTimestamp != timestampNever && widget.showTimestamp;
|
||||
final sentBySelf = widget.conversation.lastMessage?.sender == GetIt.I.get<UIDataService>().ownJid!;
|
||||
|
||||
final showBadge = widget.conversation.unreadCounter > 0 && !sentBySelf;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
@ -220,25 +242,28 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.conversation.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 17,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.showLock,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(
|
||||
Icons.lock,
|
||||
size: 17,
|
||||
RebuildOnContactIntegrationChange(
|
||||
builder: () => Text(
|
||||
widget.conversation.titleWithOptionalContact,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 17,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
...widget.titleSuffixIcon != null ?
|
||||
[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(
|
||||
widget.titleSuffixIcon,
|
||||
size: 17,
|
||||
),
|
||||
),
|
||||
] :
|
||||
[],
|
||||
Visibility(
|
||||
visible: showTimestamp,
|
||||
child: const Spacer(),
|
||||
@ -285,11 +310,11 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.extra != null,
|
||||
child: const Spacer(),
|
||||
),
|
||||
...widget.extra != null ? [widget.extra!] : [],
|
||||
...widget.extra != null ? [
|
||||
const Spacer(),
|
||||
widget.extra!
|
||||
] :
|
||||
[],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -407,6 +407,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_contacts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_contacts
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.5+1"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
@ -30,6 +30,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_bloc: 8.1.1
|
||||
flutter_blurhash: 0.7.0
|
||||
flutter_contacts: 1.1.5+1
|
||||
flutter_image_compress: 1.1.0
|
||||
flutter_isolate: 2.0.2
|
||||
flutter_localizations:
|
||||
@ -133,7 +134,7 @@ dependency_overrides:
|
||||
#moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
#omemo_dart:
|
||||
# path: ../../Personal/omemo_dart
|
||||
# path: ../../Personal/omemo_dart$
|
||||
|
||||
moxxmpp:
|
||||
git:
|
||||
|
Loading…
Reference in New Issue
Block a user