Compare commits

...

18 Commits

Author SHA1 Message Date
8f1d17636e Merge pull request 'Implement reading contact data from the phonebook' (#182) from feat/contact-integration into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/182
2022-12-12 11:54:16 +00:00
fb1c202586 refactor(service): contact.dart -> contacts.dart 2022-12-12 12:50:47 +01:00
d7a4ce022e feat(service): Reset a roster item to pseudoRosterItem on removal 2022-12-12 12:40:09 +01:00
64c3796429 feat(ui): Prevent removing a pseudo contact 2022-12-12 12:37:21 +01:00
80a517beaa fix(ui): Display pseudo roster items in the share selection 2022-12-12 12:35:16 +01:00
cec31550f8 fix(service): Handle inRoster for pseudo roster items 2022-12-12 12:23:12 +01:00
bee760adf5 feat(ui): Handle removing pseudo contacts 2022-12-12 12:18:49 +01:00
155d5747f8 feat(service): Implement pseudo roster items 2022-12-12 12:02:13 +01:00
fd531a360e feat(service): Better handle contact removal 2022-12-12 00:20:22 +01:00
c3884a460d fix(service): Remove debug command 2022-12-11 23:06:10 +01:00
5f5c30673d fix(ui): Make the UI elements react to changes of the contact integration 2022-12-11 22:22:57 +01:00
f423cd5611 feat(service): The correct avatar now appears in the notification 2022-12-11 22:01:00 +01:00
7e059e13ef fix(ui): Prevent the avatar image from flickering 2022-12-11 21:53:38 +01:00
d965fbd57e feat(service): Make the service more togglable 2022-12-10 23:08:24 +01:00
55854ec586 feat(ui): Handle contact info in the profile page 2022-12-10 22:46:42 +01:00
8886c8e695 fix(ui): Fix contact info not being retrieved 2022-12-10 22:13:22 +01:00
d58f5f9a01 feat(service): Make the contact integration configurable 2022-12-10 21:30:47 +01:00
e060b0f549 feat(service): First attempt at handling phone contacts 2022-12-10 19:34:11 +01:00
34 changed files with 950 additions and 108 deletions

View File

@ -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" />

View File

@ -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",

View File

@ -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",

View File

@ -27,7 +27,6 @@ String _cleanBase64String(String original) {
}
class AvatarService {
AvatarService() : _log = Logger('AvatarService');
final Logger _log;

300
lib/service/contacts.dart Normal file
View 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,
),
);
}
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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(),
);
}

View File

@ -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],
);
}
}

View File

@ -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(),
);
}

View File

@ -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;',
);
}

View File

@ -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)};',
);
}

View File

@ -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),

View File

@ -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: [

View File

@ -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) {

View File

@ -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;

View File

@ -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

View File

@ -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);
}

View File

@ -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.

View File

@ -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

View File

@ -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),
};
}
}

View File

@ -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 {

View File

@ -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,

View File

@ -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,
);
}
}

View File

@ -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,
),
),
],
),
),

View File

@ -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}'),
);

View File

@ -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,
),
),
);

View File

@ -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,
),
),
),
),

View File

@ -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: [

View File

@ -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: (_) {

View 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(),
);
}
}

View File

@ -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!
] :
[],
],
),
);

View File

@ -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

View File

@ -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: