@@ -12,22 +12,33 @@ import kotlinx.coroutines.withContext
1212import to.bitkit.paykit.models.Contact
1313import to.bitkit.paykit.services.DirectoryService
1414import to.bitkit.paykit.services.DiscoveredContact
15+ import to.bitkit.paykit.services.PubkySDKService
1516import to.bitkit.paykit.storage.ContactStorage
17+ import to.bitkit.utils.Logger
1618import dagger.hilt.android.lifecycle.HiltViewModel
1719import javax.inject.Inject
1820
1921/* *
20- * ViewModel for Contacts management
22+ * ViewModel for Contacts management.
23+ * Contacts are synchronized with Pubky follows - the homeserver is the source of truth.
2124 */
2225@HiltViewModel
2326class ContactsViewModel @Inject constructor(
2427 private val contactStorage : ContactStorage ,
2528 private val directoryService : DirectoryService ,
29+ private val pubkySDKService : PubkySDKService ,
2630) : ViewModel() {
2731
32+ companion object {
33+ private const val TAG = " ContactsViewModel"
34+ }
35+
2836 private val _contacts = MutableStateFlow <List <Contact >>(emptyList())
2937 val contacts: StateFlow <List <Contact >> = _contacts .asStateFlow()
3038
39+ /* * Unfiltered list of all contacts (used for searching) */
40+ private var allContacts: List <Contact > = emptyList()
41+
3142 private val _searchQuery = MutableStateFlow (" " )
3243 val searchQuery: StateFlow <String > = _searchQuery .asStateFlow()
3344
@@ -50,29 +61,89 @@ class ContactsViewModel @Inject constructor(
5061 fun loadContacts () {
5162 viewModelScope.launch {
5263 _isLoading .value = true
53- _contacts .value = contactStorage.listContacts()
64+ runCatching {
65+ // Sync contacts from Pubky follows (source of truth)
66+ val follows = withContext(Dispatchers .IO ) {
67+ directoryService.discoverContactsFromFollows()
68+ }
69+
70+ // Convert discovered contacts to Contact model and merge with local data
71+ val followContacts = follows.map { discovered ->
72+ val existing = contactStorage.getContact(discovered.pubkey)
73+ Contact (
74+ id = discovered.pubkey,
75+ publicKeyZ32 = discovered.pubkey,
76+ name = discovered.name ? : existing?.name ? : " " ,
77+ notes = existing?.notes,
78+ createdAt = existing?.createdAt ? : System .currentTimeMillis(),
79+ lastPaymentAt = existing?.lastPaymentAt,
80+ paymentCount = existing?.paymentCount ? : 0 ,
81+ )
82+ }
83+
84+ // Persist synced contacts locally for offline access
85+ contactStorage.importContacts(followContacts)
86+
87+ // Store unfiltered list for searching
88+ allContacts = followContacts
89+
90+ _contacts .value = if (_searchQuery .value.isEmpty()) {
91+ followContacts
92+ } else {
93+ followContacts.filter { contact ->
94+ contact.name.contains(_searchQuery .value, ignoreCase = true ) ||
95+ contact.publicKeyZ32.contains(_searchQuery .value, ignoreCase = true )
96+ }
97+ }
98+
99+ // Also update discovered contacts for the discovery screen
100+ _discoveredContacts .value = follows
101+
102+ Logger .debug(" Loaded ${followContacts.size} contacts from Pubky follows" , context = TAG )
103+ }.onFailure { e ->
104+ Logger .error(" Failed to load contacts from follows" , e, context = TAG )
105+ // Fallback to local storage
106+ val localContacts = contactStorage.listContacts()
107+ allContacts = localContacts
108+ _contacts .value = localContacts
109+ }
54110 _isLoading .value = false
55111 }
56112 }
57113
58114 fun searchContacts () {
59- viewModelScope.launch {
60- if (_searchQuery .value.isEmpty()) {
61- _contacts .value = contactStorage.listContacts()
62- } else {
63- _contacts .value = contactStorage.searchContacts(_searchQuery .value)
115+ // Filter from the in-memory list (not storage) to match visible list after network sync
116+ val source = allContacts.ifEmpty { contactStorage.listContacts() }
117+ _contacts .value = if (_searchQuery .value.isEmpty()) {
118+ source
119+ } else {
120+ source.filter { contact ->
121+ contact.name.contains(_searchQuery .value, ignoreCase = true ) ||
122+ contact.publicKeyZ32.contains(_searchQuery .value, ignoreCase = true )
64123 }
65124 }
66125 }
67126
68127 fun addContact (contact : Contact ) {
69128 viewModelScope.launch {
70129 runCatching {
130+ // Save locally first for immediate feedback
71131 contactStorage.saveContact(contact)
72132 loadContacts()
73133 }.onFailure { e ->
74134 _errorMessage .update { " Failed to add contact: ${e.message} " }
75135 }
136+
137+ // Then create Pubky follow in background
138+ runCatching {
139+ withContext(Dispatchers .IO ) {
140+ directoryService.addFollow(contact.publicKeyZ32)
141+ }
142+ Logger .info(" Added follow for contact: ${contact.publicKeyZ32.take(12 )} ..." , context = TAG )
143+ }.onFailure { e ->
144+ _errorMessage .update { " Failed to sync follow to Pubky: ${e.message} " }
145+ Logger .error(" Failed to add follow: ${e.message} " , context = TAG )
146+ }
76147 }
77148 }
78149
@@ -90,26 +161,31 @@ class ContactsViewModel @Inject constructor(
90161 fun deleteContact (contact : Contact ) {
91162 viewModelScope.launch {
92163 runCatching {
164+ // Delete locally first for immediate feedback
93165 contactStorage.deleteContact(contact.id)
94166 loadContacts()
95167 }.onFailure { e ->
96168 _errorMessage .update { " Failed to delete contact: ${e.message} " }
97169 }
98- }
99- }
100-
101- fun discoverContacts () {
102- viewModelScope.launch {
103- _isLoading .value = true
170+
171+ // Then remove Pubky follow in background
104172 runCatching {
105- _discoveredContacts .value = directoryService.discoverContactsFromFollows()
173+ withContext(Dispatchers .IO ) {
174+ directoryService.removeFollow(contact.publicKeyZ32)
175+ }
176+ Logger .info(" Removed follow for contact: ${contact.publicKeyZ32.take(12 )} ..." , context = TAG )
106177 }.onFailure { e ->
107- _errorMessage .update { " Failed to discover contacts: ${e.message} " }
178+ _errorMessage .update { " Failed to sync unfollow to Pubky: ${e.message} " }
179+ Logger .error(" Failed to remove follow: ${e.message} " , context = TAG )
108180 }
109- _isLoading .value = false
110181 }
111182 }
112183
184+ fun discoverContacts () {
185+ // Discovery is now unified with loadContacts - just refresh from follows
186+ loadContacts()
187+ }
188+
113189 fun importDiscovered (contacts : List <Contact >) {
114190 viewModelScope.launch {
115191 runCatching {
@@ -132,24 +208,56 @@ class ContactsViewModel @Inject constructor(
132208
133209 fun followContact (pubkey : String ) {
134210 viewModelScope.launch {
211+ _isLoading .value = true
135212 runCatching {
136213 withContext(Dispatchers .IO ) {
214+ // 1. Add follow to Pubky homeserver
137215 directoryService.addFollow(pubkey)
216+
217+ // 2. Fetch profile for the followed user
218+ val profile = runCatching { pubkySDKService.fetchProfile(pubkey) }.getOrNull()
219+
220+ // 3. Add to local contacts
221+ val contact = Contact (
222+ id = pubkey,
223+ publicKeyZ32 = pubkey,
224+ name = profile?.name ? : " " ,
225+ notes = null ,
226+ createdAt = System .currentTimeMillis(),
227+ lastPaymentAt = null ,
228+ paymentCount = 0 ,
229+ )
230+ contactStorage.saveContact(contact)
231+
232+ Logger .info(" Followed and added contact: $pubkey " , context = TAG )
138233 }
234+ // 4. Refresh contacts list
235+ loadContacts()
139236 }.onFailure { e ->
237+ Logger .error(" Failed to follow: ${e.message} " , context = TAG )
140238 _errorMessage .update { " Failed to follow: ${e.message} " }
239+ _isLoading .value = false
141240 }
142241 }
143242 }
144243
145244 fun unfollowContact (pubkey : String ) {
146245 viewModelScope.launch {
246+ _isLoading .value = true
147247 runCatching {
148248 withContext(Dispatchers .IO ) {
249+ // Remove follow from Pubky homeserver
149250 directoryService.removeFollow(pubkey)
251+ // Remove from local contacts
252+ contactStorage.deleteContact(pubkey)
253+ Logger .info(" Unfollowed and removed contact: $pubkey " , context = TAG )
150254 }
255+ // Refresh contacts list
256+ loadContacts()
151257 }.onFailure { e ->
258+ Logger .error(" Failed to unfollow: ${e.message} " , context = TAG )
152259 _errorMessage .update { " Failed to unfollow: ${e.message} " }
260+ _isLoading .value = false
153261 }
154262 }
155263 }
0 commit comments